From c3ebf21e9c399ebde6a97acac9d0e2139a0bb5a2 Mon Sep 17 00:00:00 2001 From: retoor Date: Fri, 23 Oct 2026 22:08:37 +0200 Subject: [PATCH] AppArmor profile per binary path with install hint; verified network examples Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 6 ++++++ README.md | 48 +++++++++++++++++++++++++++++++++++++++++++++--- SPEC.md | 4 +++- VERSION | 2 +- apparmor/doos | 3 ++- src/cli.cpp | 7 +++++++ src/cli.hpp | 1 + src/sandbox.cpp | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/sandbox.hpp | 5 +++++ tests/run.sh | 13 +++++++++++++ 11 files changed, 133 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 73b0727..17664b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ the script. slirp4netns, filesystem, real downloads of the Alpine and Ubuntu images, real apt/apk installs, the size freeze (about 4 minutes in total, needs network, `pasta`, `gpg` and cgroup v2 delegation). Add a case for every behavior change. A green run is required before release. On Ubuntu 24.04+ the AppArmor profile in `apparmor/doos` must be -installed once (needs sudo, ask the user, do not do it yourself). +installed once for the binary being tested (`doos apparmor`; needs sudo, ask the user, do not do it yourself). ## Environment notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 977cff8..9a03b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.1 - 2026-10-23 +- `doos apparmor` prints a profile for the exact path of the running binary (own profile name per path, so several installs coexist). A start that fails because of AppArmor now prints the install command instead of only `mount bind /: Permission denied`. Verified with a copy in `~/.local/bin`. +- `apparmor/doos` (packaging) covers more standard install paths. +- Docs: Networking section with a verified example per mode (slirp, pasta, host, none), the inbound/loopback behavior of each, and the pasta port-detection delay. +- Tests: 86 checks (adds apparmor output, `none`, slirp has no inbound path, pasta container to container). + ## 1.1.0 - 2026-10-23 - Limits: `--memory`, `--pids`, `--cpus` (cgroup v2; whole-container OOM kill; reports OOM kills and pid-limit hits). Later attaches run in their own cgroup leaf with the same limits. - Hardening (default): Docker's default capability set, a seccomp filter (arch check, x32 guard, clone3 ENOSYS, no nested user namespaces, no mounts/keyrings/bpf/perf/io_uring/...), host `/etc` allowlist. `--hardening relaxed` turns it off per container. diff --git a/README.md b/README.md index 9a8e975..390b34a 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ doos get_or_create [OPTIONS] NAME doos attach [OPTIONS] NAME [-- CMD...] doos delete [--yes] NAME doos image list | add [--sha256 HEX] SOURCE [NAME] | delete [--yes] NAME +doos apparmor # profile text for this binary (Ubuntu 24.04+) ``` -Version: see `VERSION` (currently 1.1.0). Release history: `CHANGELOG.md`. +Version: see `VERSION` (currently 1.1.1). Release history: `CHANGELOG.md`. User-level containers in C++20: overlayfs over the host's `/usr`, `/etc`, `/opt`, user/mount/pid namespaces, networking through slirp4netns. No root at runtime. @@ -23,12 +24,17 @@ doos delete [--yes] NAME Build: `cmake -S . -B build && cmake --build build`. Test: `tests/run.sh`. -Requirements: Linux >= 5.11, `slirp4netns` (default network mode), `pasta` (only for `--net pasta`), `gnupg` (signature checks; optional), `uidmap` (`newuidmap`/`newgidmap`, for more than one id inside; falls back to one id), `curl`, `tar` and `sha256sum` (images). On Ubuntu 24.04+ install the AppArmor profile once: +Requirements: Linux >= 5.11, `slirp4netns` (default network mode), `pasta` (only for `--net pasta`), `gnupg` (signature checks; optional), `uidmap` (`newuidmap`/`newgidmap`, for more than one id inside; falls back to one id), `curl`, `tar` and `sha256sum` (images). On Ubuntu 24.04+ (`kernel.apparmor_restrict_unprivileged_userns=1`) AppArmor must allow user namespaces for the +binary. Generate a profile for the exact path doos is installed at, once: ``` -sudo cp apparmor/doos /etc/apparmor.d/doos && sudo apparmor_parser -r /etc/apparmor.d/doos +doos apparmor | sudo tee /etc/apparmor.d/doos-local >/dev/null && sudo apparmor_parser -r /etc/apparmor.d/doos-local ``` +If you skip this, doos tells you the exact command (with a file name unique to the binary's path, so installing doos +in a second place does not replace the first profile). `apparmor/doos` is a static profile for packaging that covers +`/usr/local/bin`, `/usr/bin`, `/opt/doos/bin`, `~/.local/bin`, `~/bin` and `~/projects/doos/build`. + See `SPEC.md` (behavior, limits) and `docs/RESEARCH.md` (what works, measurements, known gaps). ## Usage examples @@ -67,6 +73,41 @@ doos attach --image mybase c1 Writes always stay in the container (copy-on-write); `/root` and `/var` persist, `/tmp` is memory-backed and lost on exit. A container is `running` while its first `attach` command is alive; more `attach` calls join it. Data persists until `delete --yes`. +## Networking + +Every example below was run on the development machine. Pick the mode when you create the container; it cannot change later. + +| Mode | Outbound | Host `127.0.0.1` from inside | Reach a port served inside, from the host | Host abstract unix sockets | +|---|---|---|---|---| +| `slirp` (default) | yes | blocked | no | not shared | +| `pasta` | yes | reachable | yes (forwarded automatically) | not shared | +| `host` | yes | the same network | yes | **shared** | +| `none` | no | no | no | not shared | + +``` +# slirp (default): the internet works, the host's loopback does not +doos attach web -- curl -sS -o /dev/null -w "%{http_code}\n" https://example.com # 200 +doos attach web -- curl -sS -m 3 http://127.0.0.1:8099 # fails: host loopback is blocked +doos attach web -- getent hosts example.com # DNS works (resolver 10.0.2.3) + +# pasta: behaves like the host network, in a private namespace +doos attach --net pasta dev -- curl -sS http://127.0.0.1:8099 # reaches a service on the host's loopback +doos attach dev -- python3 -m http.server 8088 --bind 0.0.0.0 # then, on the host: curl http://127.0.0.1:8088/ +doos attach --image alpine --net pasta al -- wget -qO- https://example.com # works with images too + +# none: no network at all, only loopback +doos attach --net none iso -- curl -m 3 https://example.com # curl exits 6, /proc/net/dev lists only lo + +# host: the host's own network namespace, explicit opt-in (prints a note recommending pasta) +doos attach --net host h -- curl -sS -o /dev/null -w "%{http_code}\n" https://example.com # 200 +``` + +Notes, all measured: +- `slirp` has no inbound path: a server started in a `slirp` container is not reachable from the host (connection fails). Use `pasta` for that. +- `pasta` notices a newly bound port after about 2 seconds, so start the server and give it a moment before connecting from the host. +- Two `pasta` containers can talk to each other through the host's loopback: start a server in one (`python3 -m http.server 8066 --bind 0.0.0.0`), wait about 5 seconds, then `doos attach cli -- curl http://127.0.0.1:8066/` from the other returns 200. The host's own public address is not forwarded; use `127.0.0.1` or the default gateway address. +- `pasta` starts in about 65 ms per attach, `slirp` about 40 ms, `none` about 10 ms. + ## Options | Option | Meaning | Default | Later | @@ -108,6 +149,7 @@ Changeable options apply the next time the container starts. Examples: `doos att | `limits are not enforced and the size cap only warns` | No delegated cgroup v2 (no systemd user manager). Limits and cap are advisory. | | `was killed by its memory limit` | Raise `--memory`. `/tmp` is memory-backed and counts. | | `hit its process limit` | Raise `--pids`; threads count as processes. | +| `AppArmor restricts user namespaces for this binary` | Run the command doos prints (`doos apparmor \| sudo tee ... && sudo apparmor_parser -r ...`), once per install path. | | `Operation not permitted` from `mount`, `unshare`, `keyctl`, io_uring | Hardening blocked it. Use `--hardening relaxed` on that container. | | `signature NOT checked (gpg is not installed)` | Install `gnupg` for signature checks; the checksum still applies. | | `doos: seeded ...: unreadable skipped: N` | Host entries the user cannot read were left out of the container. Informational. | diff --git a/SPEC.md b/SPEC.md index 2a9feec..2f63ebf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -12,6 +12,7 @@ Authoritative description of behavior and limits. Code constants live in `src/sp | `doos delete [--yes] NAME` | Dry run unless `--yes`. Refuses a running container. | | `doos image list` | Installed images (SIZE, IDS) plus catalog entries marked `available`. | | `doos image add [--sha256 HEX] SOURCE [NAME]` | Installs an image. See Images. | +| `doos apparmor` | Prints an AppArmor profile (named after the binary's path) that lets this binary use user namespaces. A start that fails with EPERM/EACCES while `apparmor_restrict_unprivileged_userns=1` prints the command to install it. | | `doos image delete [--yes] NAME` | Dry run unless `--yes`. Refuses an image used by any container, even with `--yes`. | `get-or-create` is accepted as an alias. @@ -121,7 +122,8 @@ User, mount, pid, uts, ipc namespaces always; network namespace unless `host`. I - `slirp`: private netns, `slirp4netns --configure --disable-host-loopback`, DNS 10.0.2.3, outbound only, host 127.0.0.1 unreachable. - `none`: private netns with loopback only. -- `pasta`: private netns that behaves like the host network: addresses and routes mirrored, host loopback reachable, ports bound inside are forwarded to the host, DNS from the host's resolver. The host's abstract unix sockets are not shared. Needs `pasta`. +- `slirp` has no inbound path (nothing outside reaches a port served inside). +- `pasta`: private netns that behaves like the host network: addresses and routes mirrored, host loopback reachable, ports bound inside are forwarded to the host, DNS from the host's resolver. The host's abstract unix sockets are not shared. Needs `pasta`. Newly bound ports are picked up after about 2 s; only loopback and the default gateway address reach other containers' forwarded ports, not the host's public address. - `host`: shares the host network namespace including its abstract unix sockets (explicit opt-in; a note recommends `pasta`). ## Safety rules diff --git a/VERSION b/VERSION index 9084fa2..524cb55 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.1.1 diff --git a/apparmor/doos b/apparmor/doos index ae75a51..2a24614 100644 --- a/apparmor/doos +++ b/apparmor/doos @@ -2,10 +2,11 @@ # kernel.apparmor_restrict_unprivileged_userns=1 strips capabilities (mount, etc.) # from unknown binaries inside a new userns. Same pattern as /etc/apparmor.d/lxc-unshare. # The profile is unconfined apart from granting `userns`; confinement comes from doos itself. +# Fixed paths for packaging. For any other location run: doos apparmor | sudo tee /etc/apparmor.d/doos abi , include -profile doos /{usr/local/bin,home/*/projects/doos/build}/doos flags=(unconfined) { +profile doos /{usr/local/bin,usr/bin,opt/doos/bin,home/*/.local/bin,home/*/bin,home/*/projects/doos/build}/doos flags=(unconfined) { userns, @{exec_path} mr, } diff --git a/src/cli.cpp b/src/cli.cpp index 2a97ded..ed5f36d 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -112,6 +112,7 @@ void Cli::usage(std::FILE* out) { " doos image list\n" " doos image add [--sha256 HEX] SOURCE [NAME]\n" " doos image delete [--yes] NAME\n" + " doos apparmor print the AppArmor profile this binary needs on Ubuntu 24.04+\n" "\n" "options:\n" " --image NAME start from an image instead of the host system (fetched on demand) [fixed]\n" @@ -144,6 +145,7 @@ int Cli::run() { if (cmd == "attach") return cmdAttach(rest); if (cmd == "delete") return cmdDelete(rest); if (cmd == "image") return cmdImage(rest); + if (cmd == "apparmor") return cmdApparmor(); std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str()); usage(stderr); return 2; @@ -187,6 +189,11 @@ int Cli::cmdDelete(std::vector rest) { return 0; } +int Cli::cmdApparmor() { + std::fputs(Sandbox::apparmorProfile().c_str(), stdout); + return 0; +} + int Cli::cmdImage(std::vector rest) { if (rest.empty()) throw std::runtime_error("image needs a subcommand: list, add, delete"); std::string sub = rest[0]; diff --git a/src/cli.hpp b/src/cli.hpp index ee3ea03..a58d49f 100644 --- a/src/cli.hpp +++ b/src/cli.hpp @@ -15,6 +15,7 @@ private: int cmdGetOrCreate(std::vector rest); int cmdAttach(std::vector rest); int cmdDelete(std::vector rest); + int cmdApparmor(); int cmdImage(std::vector rest); int cmdImageList(); int cmdImageAdd(std::vector rest); diff --git a/src/sandbox.cpp b/src/sandbox.cpp index 9a162aa..a97fbd9 100644 --- a/src/sandbox.cpp +++ b/src/sandbox.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include @@ -293,12 +294,60 @@ int containerInit(void* arg) { ::_exit(code); } catch (const std::exception& e) { ::dprintf(2, "doos: %s\n", e.what()); + auto* se = dynamic_cast(&e); + std::ifstream restrictUserns("/proc/sys/kernel/apparmor_restrict_unprivileged_userns"); + int restricted = 0; + if (restrictUserns) restrictUserns >> restricted; + if (se && (se->code() == EPERM || se->code() == EACCES) && restricted == 1) { + ::dprintf(2, "doos: AppArmor restricts user namespaces for this binary. Install a profile once with:\n %s\n", + Sandbox::apparmorInstallHint().c_str()); + } ::_exit(125); } } } // namespace +namespace { +fs::path selfPath() { + std::error_code ec; + fs::path self = fs::read_symlink("/proc/self/exe", ec); + if (ec) throw std::runtime_error("cannot resolve the path of this binary: " + ec.message()); + std::string path = self.string(); + if (path.find_first_of("{}[]*?\"\n,") != std::string::npos) { + throw std::runtime_error("binary path contains characters AppArmor treats as globs: " + path); + } + return self; +} + +// One profile per binary path, so installing doos in a second place does not replace the first profile. +std::string profileName(const fs::path& self) { + std::string n = "doos" + self.string(); + for (auto& c : n) { + if (c == '/') c = '-'; + } + return n; +} +} // namespace + +std::string Sandbox::apparmorProfile() { + fs::path self = selfPath(); + return "# Lets doos use unprivileged user namespaces where kernel.apparmor_restrict_unprivileged_userns=1.\n" + "# Generated by: doos apparmor (profile is for exactly this path)\n" + "abi ,\n" + "include \n" + "\n" + "profile " + profileName(self) + " " + self.string() + " flags=(unconfined) {\n" + " userns,\n" + " @{exec_path} mr,\n" + "}\n"; +} + +std::string Sandbox::apparmorInstallHint() { + std::string file = "/etc/apparmor.d/" + profileName(selfPath()); + return "doos apparmor | sudo tee " + file + " >/dev/null && sudo apparmor_parser -r " + file; +} + int Sandbox::run() { if (command_.size() > spec::kMaxCommandArgs) throw std::runtime_error("too many command arguments"); // Decide start-or-join under the lock so concurrent attaches agree. diff --git a/src/sandbox.hpp b/src/sandbox.hpp index 7d373cf..b85a89d 100644 --- a/src/sandbox.hpp +++ b/src/sandbox.hpp @@ -18,6 +18,11 @@ public: int run(); + // AppArmor profile (text) that lets this very binary use user namespaces on Ubuntu 24.04+. + static std::string apparmorProfile(); + // Shell command that installs it; shown when a start fails because of AppArmor. + static std::string apparmorInstallHint(); + private: int start(FileLock& lock); int watch(pid_t child, Cgroup* cgroup); diff --git a/tests/run.sh b/tests/run.sh index a9b5361..0552876 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -178,6 +178,19 @@ grep -q hello /tmp/doos-in.$$ && ok "pasta: inbound port forwarded" || bad "past rm -f /tmp/doos-in.$$; kill $lp 2>/dev/null; wait 2>/dev/null "$DOOS" delete --yes pa >/dev/null +# ---- documented network examples that were not covered yet, and the apparmor command +echo "--- network examples" +"$DOOS" apparmor | grep -q "profile doos.* $DOOS flags=(unconfined)" && ok "apparmor: profile names this binary" || bad "apparmor: profile names this binary" +"$DOOS" attach --net none iso -- sh -c 'curl -sS -m 3 https://example.com >/dev/null 2>&1; [ $? -eq 6 ] && [ "$(tail -n +3 /proc/net/dev | cut -d: -f1 | tr -d " ")" = lo ]' >/dev/null 2>&1 && ok "none: curl exits 6, only lo" || bad "none: curl exits 6, only lo" +"$DOOS" attach nets -- timeout 5 python3 -m http.server 8077 --bind 0.0.0.0 >/dev/null 2>&1 & sleep 2.5 +curl -sS -m 2 -o /dev/null http://127.0.0.1:8077/ >/dev/null 2>&1 && bad "slirp: no inbound path" || ok "slirp: no inbound path" +wait %% 2>/dev/null +"$DOOS" get_or_create --net pasta pa1 >/dev/null 2>&1; "$DOOS" get_or_create --net pasta pa2 >/dev/null 2>&1 +"$DOOS" attach pa1 -- timeout 12 python3 -m http.server 8066 --bind 0.0.0.0 >/dev/null 2>&1 & sleep 5 +[ "$("$DOOS" attach pa2 -- curl -sS -m 2 -o /dev/null -w '%{http_code}' http://127.0.0.1:8066/ 2>/dev/null)" = "200" ] && ok "pasta: container to container via loopback" || bad "pasta: container to container via loopback" +wait 2>/dev/null +"$DOOS" delete --yes pa1 >/dev/null; "$DOOS" delete --yes pa2 >/dev/null; "$DOOS" delete --yes nets >/dev/null; "$DOOS" delete --yes iso >/dev/null + # ---- signatures and more catalog images echo "--- signatures" "$DOOS" image add alpine 2>&1 | grep -q "signature verified" && ok "alpine signature verified against pinned key" || bad "alpine signature verified against pinned key"