Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ebf21e9c | ||
|
|
530582e826 |
@@ -9,8 +9,9 @@ part of every release.
|
||||
`doos` is a user-level container tool in C++20. `attach` builds a user + mount + pid
|
||||
+ uts + ipc (+ net) namespace set, overlays either the host's `/usr`, `/opt` (host mode) or an
|
||||
extracted image (image mode) with a per-container upper dir, pivots into it and runs a command.
|
||||
Networking is slirp4netns, ids are mapped through newuidmap when possible, a watchdog enforces
|
||||
`--size`. No root at runtime. Authoritative behavior: `SPEC.md`. Design notes: `docs/ARCHITECTURE.md`.
|
||||
Networking is slirp4netns or pasta, ids are mapped through newuidmap when possible, cgroup v2 limits
|
||||
and a watchdog enforce `--memory --pids --cpus --size`, capabilities and seccomp confine the command.
|
||||
No root at runtime. Authoritative behavior: `SPEC.md`. Design notes: `docs/ARCHITECTURE.md`.
|
||||
Measurements and gaps: `docs/RESEARCH.md`.
|
||||
|
||||
## Mandatory release workflow (every change, same session)
|
||||
@@ -41,7 +42,9 @@ Never hand-edit `VERSION` or create tags manually. Never skip the tests to relea
|
||||
| A new measurement | `docs/RESEARCH.md` with machine, kernel, and what was run |
|
||||
| A new known gap or a closed gap | `docs/RESEARCH.md` gaps list and `README.md` |
|
||||
| These workflow rules | this file |
|
||||
| Image catalog, sources, verification | `SPEC.md` Images, `README.md`, `docs/RESEARCH.md` |
|
||||
| Image catalog, sources, verification, pinned keys | `SPEC.md` Images, `README.md`, `docs/RESEARCH.md` |
|
||||
| Seccomp list, capability set, `/etc` allowlist | `SPEC.md` Hardening and filesystem, `README.md`, `docs/RESEARCH.md` |
|
||||
| Limits, cgroup layout | `SPEC.md` Resource limits, `docs/ARCHITECTURE.md` |
|
||||
| Anything above, in every release | `README.md` version line and `CHANGELOG.md` entry |
|
||||
|
||||
Docs describe what the system does today. No roadmap text. A gap is listed next to
|
||||
@@ -68,9 +71,9 @@ the script.
|
||||
|
||||
`tests/run.sh [path/to/doos]` runs the real path: real overlayfs, namespaces,
|
||||
slirp4netns, filesystem, real downloads of the Alpine and Ubuntu images, real apt/apk installs, the size
|
||||
freeze (about 3 minutes in total, needs network). Add a case for every behavior change. A green run is required
|
||||
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
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 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.
|
||||
- Networking: `--net pasta` (host-like reachability, private namespace and abstract sockets). `--net host` now prints a note.
|
||||
- Size cap: measured from inside the id map (counts other uids), paced by scan cost (2 s floor), `RLIMIT_FSIZE` bounds single files.
|
||||
- Images: Debian in the catalog (`debian`, `debian-RELEASE`); Alpine and Ubuntu signatures verified against pinned key fingerprints.
|
||||
- Fix: a failed `get_or_create` no longer leaves an unreadable container directory behind.
|
||||
- Tests: 83 end-to-end checks.
|
||||
|
||||
## 1.0.0 - 2026-10-23
|
||||
Breaking: state layout changed (private `/etc`, `meta` keys). Containers from 0.x must be deleted and recreated.
|
||||
- Images: `doos image list|add|delete`, `--image NAME` on `get_or_create`/`attach` (fetched on demand). Catalog: `alpine`, `ubuntu`, `ubuntu-VERSION`; also https URL with `--sha256`, or a local tarball. sha256 verified before extraction.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ if(NOT CMAKE_BUILD_TYPE)
|
||||
endif()
|
||||
|
||||
add_executable(doos
|
||||
src/main.cpp src/idmap.cpp src/image.cpp src/cgroup.cpp src/cli.cpp src/container.cpp src/network.cpp src/sandbox.cpp src/util.cpp)
|
||||
src/main.cpp src/hardening.cpp src/idmap.cpp src/image.cpp src/cgroup.cpp src/cli.cpp src/container.cpp src/network.cpp src/sandbox.cpp src/util.cpp)
|
||||
target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic)
|
||||
target_compile_definitions(doos PRIVATE DOOS_VERSION="${DOOS_VERSION}")
|
||||
install(TARGETS doos RUNTIME DESTINATION bin)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# doos
|
||||
|
||||
User-level containers in C++20: overlayfs, user/mount/pid namespaces, slirp4netns networking, optional images (Alpine, Ubuntu, or your own rootfs), a size cap per container. No root at runtime.
|
||||
User-level containers in C++20: overlayfs, user/mount/pid namespaces, slirp4netns or pasta networking, optional images (Alpine, Ubuntu, Debian, or your own rootfs), memory/cpu/pid/size limits, default hardening (capabilities, seccomp). No root at runtime.
|
||||
|
||||
```
|
||||
doos list
|
||||
doos get_or_create [--image I] [--size S] [--net M] [--ids X] NAME
|
||||
doos attach [--image I] [--size S] [--net M] [--ids X] NAME [-- CMD...]
|
||||
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.0.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), `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,9 +73,62 @@ 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`.
|
||||
|
||||
## Size cap
|
||||
## Networking
|
||||
|
||||
`--size` limits what a container writes (default 4g, 64m to 1t). The parent checks every 10 s: a warning at 80%, at 100% the container is frozen (nothing is deleted), and it thaws on its own below 95% after you free space with a second `doos attach NAME -- ...`. Needs cgroup v2 delegation (standard on systemd desktops); without it the cap only warns.
|
||||
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 |
|
||||
|---|---|---|---|
|
||||
| `--image NAME` | alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, or your own | host system | fixed |
|
||||
| `--net M` | `slirp` (outbound only, host loopback blocked), `pasta` (host-like, private sockets), `host`, `none` | slirp | fixed |
|
||||
| `--ids X` | `single` or `subuid` | subuid if possible | fixed |
|
||||
| `--hardening H` | `default` or `relaxed` | default | fixed |
|
||||
| `--size S` | writable data cap | 4g | changeable |
|
||||
| `--memory S` | memory cap | 8g | changeable |
|
||||
| `--pids N` | process/thread cap | 4096 | changeable |
|
||||
| `--cpus N` | cpu cap, e.g. `0.5`, `2` | all cores | changeable |
|
||||
|
||||
Changeable options apply the next time the container starts. Examples: `doos attach --memory 2g --cpus 1 build`, `doos attach --net pasta --image debian-bookworm web`.
|
||||
|
||||
## Limits, size cap and hardening
|
||||
|
||||
- **Limits** (`--memory`, `--pids`, `--cpus`): cgroup v2, measured here: a 256m container touching 400 MB is killed whole and doos says so; 80 forks against `--pids 32` fail and are reported; `--cpus 0.5` held 4 busy loops to 2.3 CPU-seconds in 4.3 s of wall time.
|
||||
- **Size cap**: `--size` limits what a container writes. Checked from inside the id map (so other users' files count), warns at 80%, freezes at 100% (nothing is deleted), thaws below 95%. A second `doos attach NAME -- ...` is never frozen, so you can clean up. No single file can exceed the cap.
|
||||
- **Hardening** (default): capabilities reduced to Docker's default set, a seccomp filter (no mounts, keyrings, bpf, perf, io_uring, nested user namespaces, kexec, module loading...), and a host `/etc` allowlist instead of a full copy. `--hardening relaxed` switches all three off for tools that need them (container runtimes, Chromium's sandbox).
|
||||
- All limits need cgroup v2 delegation (standard on systemd desktops). Without it doos prints that nothing is enforced.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -87,7 +146,12 @@ Writes always stay in the container (copy-on-write); `/root` and `/var` persist,
|
||||
| `subuid mapping unavailable` | Install `uidmap` and make sure `/etc/subuid` and `/etc/subgid` list your user, or use `--ids single`. |
|
||||
| `sha256 mismatch` | The download is not what the publisher lists; nothing was installed. Retry or check the source. |
|
||||
| `a URL image needs --sha256` | Unverifiable input is rejected by design; pass the hash from the publisher. |
|
||||
| `size cap only warns, cannot freeze` | No delegated cgroup v2 (no systemd user manager). Cap is advisory. |
|
||||
| `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. |
|
||||
| `state path must not contain ',' or ':'` | Set `DOOS_HOME` to a plain path. |
|
||||
| `container 'x' is running` on delete | Exit the attached command first. |
|
||||
@@ -95,11 +159,12 @@ Writes always stay in the container (copy-on-write); `/root` and `/var` persist,
|
||||
|
||||
## Safety summary
|
||||
|
||||
Host files are never written: system dirs are reached only through overlayfs. Host
|
||||
`/home`, `/sys` and host loopback are not reachable in the default mode. Images are
|
||||
verified against the publisher's checksum before they are extracted. `--net host`
|
||||
removes network isolation and is opt-in. Known gaps (no cgroups, readable host `/etc`)
|
||||
are listed with researched solutions (G1 to G6, not implemented) in `docs/RESEARCH.md`.
|
||||
Host files are never written: system dirs are reached only through overlayfs. Host `/home`, `/sys` and host
|
||||
loopback are not reachable in the default network mode. Images are verified before they are extracted: sha256
|
||||
from the publisher, and for Alpine and Ubuntu a signature checked against a key pinned by fingerprint. Processes
|
||||
run with Docker's default capability set and a seccomp filter. Memory, pids, cpus and written data are capped.
|
||||
`--net host` removes network isolation and is opt-in (`--net pasta` is the safer host-like choice).
|
||||
What is still not covered is listed at the end of `docs/RESEARCH.md`.
|
||||
|
||||
## Project docs
|
||||
|
||||
|
||||
@@ -12,20 +12,25 @@ 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.
|
||||
|
||||
Container options (all four commands that take NAME-creating options: `get_or_create`, `attach`):
|
||||
Container options (accepted by `get_or_create` and `attach`):
|
||||
|
||||
| Option | Meaning | Default | Changeable later |
|
||||
|---|---|---|---|
|
||||
| `--image NAME` | Start from an image instead of the host system. Missing catalog images are fetched on demand. | none (host mode) | no |
|
||||
| `--size SIZE` | Cap for what the container writes (`512m`, `8g`, `1t`; k/m/g/t). | 4g | yes, on any later get_or_create/attach |
|
||||
| `--net slirp\|host\|none` | Network mode. | slirp | no |
|
||||
| `--net slirp\|pasta\|host\|none` | Network mode, see Networking. | slirp | no |
|
||||
| `--ids single\|subuid` | Id mapping. | subuid when available, else single | no |
|
||||
| `--hardening default\|relaxed` | `relaxed` = no capability drop, no seccomp filter, full readable host `/etc` (host mode). | default | no |
|
||||
| `--size SIZE` | Cap for what the container writes (`512m`, `8g`, `1t`; k/m/g/t). | 4g | yes |
|
||||
| `--memory SIZE` | Memory cap (cgroup `memory.max`, swap 0, whole-container OOM kill). | 8g | yes |
|
||||
| `--pids N` | Process and thread cap (`pids.max`). | 4096 | yes |
|
||||
| `--cpus N` | CPU cap, decimals allowed (`0.5`, `2`); `0` = all cores. | 0 | yes |
|
||||
|
||||
Naming an option on an existing container with a different fixed value is an error, never a silent change.
|
||||
Changeable options apply at the next start of the container. Naming a fixed option on an existing container with a different value is an error, never a silent change.
|
||||
|
||||
## Limits (hard caps)
|
||||
|
||||
@@ -38,7 +43,11 @@ Naming an option on an existing container with a different fixed value is an err
|
||||
| Open files inside | 4096 |
|
||||
| `/tmp`, `/run`, `/dev/shm` | 256m, 64m, 64m (tmpfs) |
|
||||
| `--size` | 64m to 1t |
|
||||
| Size scan interval | 10 s (floor, never faster) |
|
||||
| Size scan interval | 2 s floor, and at least 10x the duration of the last scan |
|
||||
| `--memory` | 64m to 1t |
|
||||
| `--pids` | 16 to 4,194,304 |
|
||||
| `--cpus` | 0.1 to 1024 |
|
||||
| Single file size inside | the `--size` cap (`RLIMIT_FSIZE`) |
|
||||
| Size scan entries | 5,000,000 |
|
||||
| Image download | 2 GiB, 900 s |
|
||||
| Skeleton directories | 200,000 |
|
||||
@@ -65,7 +74,7 @@ images/NAME/{meta,rootfs/}
|
||||
|---|---|
|
||||
| `/usr /opt` (and `/bin /sbin /lib /lib32 /lib64` when real dirs) | overlayfs, lower = host dir, upper = `layers/<dir>/upper`, with a directory skeleton in the upper |
|
||||
| `/bin /sbin /lib...` when host symlinks | same symlink recreated |
|
||||
| `/etc` | private copy of the readable host files, made once at creation |
|
||||
| `/etc` | private copy, made once at creation: under `--hardening default` only the allowlist in `spec::kEtcAllow` (passwd, group, nsswitch, ld.so*, ssl, ca-certificates, alternatives, apt, dpkg, shell and tool defaults...) minus names that may hold credentials (`private`, `shadow`, `gshadow`, `sudoers*`, `auth.conf*`, `ssh_host_*`, `*.key`); under `relaxed` every readable file |
|
||||
| `/root`, `/var` | persistent private dirs; `/var` pre-seeded with the apt/dpkg directories |
|
||||
| `/var/lib/dpkg` | overlay of the host dpkg database (when the host has it), regular files copied into the upper, lock files pre-created |
|
||||
| `/tmp /run /dev/shm` | tmpfs |
|
||||
@@ -85,16 +94,25 @@ Why the skeleton and copies: host root-owned files appear as `nobody` inside the
|
||||
|
||||
| Source | Resolution | Verification |
|
||||
|---|---|---|
|
||||
| `alpine` | latest-stable minirootfs from `dl-cdn.alpinelinux.org`, via `latest-releases.yaml` | sha256 from the yaml, over https |
|
||||
| `ubuntu`, `ubuntu-VERSION` | newest `ubuntu-base-*-base-ARCH.tar.gz` of that release from `cdimage.ubuntu.com` (default `26.04`) | sha256 from `SHA256SUMS`, over https |
|
||||
| `alpine` | latest-stable minirootfs from `dl-cdn.alpinelinux.org`, via `latest-releases.yaml` | sha256 from the yaml, and the detached `.asc` signature checked against Alpine's pinned release key (`0482D840 22F52DF1 C4E7CD43 293ACD09 07D9495A`) |
|
||||
| `ubuntu`, `ubuntu-VERSION` | newest `ubuntu-base-*-base-ARCH.tar.gz` of that release from `cdimage.ubuntu.com` (default `26.04`) | `SHA256SUMS` checked with `SHA256SUMS.gpg` against Ubuntu's pinned CD image key (`84393 8DF 228D 22F7 B374 2BC0 D94A A3F0 EFE2 1092`), then sha256 |
|
||||
| `debian`, `debian-RELEASE` | `debuerreotype/docker-debian-artifacts` OCI layer for `RELEASE` (default `trixie`), slim variant | sha256 = the layer digest in the OCI manifest; no publisher signature exists |
|
||||
| `https://...` | downloaded | `--sha256 HEX` required, otherwise rejected |
|
||||
| a file path | read locally | verified when `--sha256` is given; otherwise used with a printed note |
|
||||
|
||||
`http://` is refused. A mismatch deletes everything it downloaded. The tarball is extracted with `tar -p --numeric-owner`, excluding `dev/*`, inside a user namespace with the image's id mode. Architectures: x86_64 and aarch64 for the catalog.
|
||||
`http://` is refused. Signature keys are fetched over https and rejected unless their fingerprint equals the pinned one; a bad, missing or foreign signature rejects the image. Without `gpg` installed the signature step is skipped with a printed note and the checksum still applies. A mismatch deletes everything it downloaded. The tarball is extracted with `tar -p --numeric-owner`, excluding `dev/*`, inside a user namespace with the image's id mode. Architectures: x86_64 and aarch64 for the catalog.
|
||||
|
||||
## Size cap
|
||||
|
||||
The parent process scans the container's writable data every 10 s (hard floor). At 80% it warns once on stderr. At 100% it freezes the container's cgroup (`cgroup.freeze`); processes stop, nothing is deleted. It thaws on its own once usage is below 95%. A second `doos attach` is not frozen, so the user can free space from inside. Without cgroup v2 delegation the cap only warns and says so at start.
|
||||
The parent process measures the container's writable data (upper layers and private dirs, hard links counted once) from inside the container's id map, so files and mode-700 directories of other container ids are counted. A scan runs at most every 2 s and never uses more than 1/10 of the time (the wait is at least 10x the last scan). At 80% it warns once on stderr. At 100% it freezes the container's `main` cgroup (`cgroup.freeze`); processes stop, nothing is deleted. It thaws on its own once usage is below 95%. A second `doos attach` runs in the `shell` cgroup and is not frozen, so the user can free space from inside. No file can exceed the cap on its own (`RLIMIT_FSIZE`), which bounds the overshoot between scans. Without cgroup v2 delegation the cap only warns and says so at start.
|
||||
|
||||
## Resource limits
|
||||
|
||||
Each running container gets `doos/<name>/` below the cgroup delegated to the user's systemd manager, with leaves `main` (init and its descendants) and `shell` (later attaches). Limits sit on `doos/<name>` and cover both leaves: `memory.max` and `memory.swap.max=0` with `memory.oom.group=1` (an OOM kill takes the whole container), `pids.max`, `cpu.max`. After exit doos reports OOM kills and process-limit hits. A missing controller is named on stderr and the other limits still apply. Without delegation no limit is enforced, and doos says so at start. The group is removed when the container stops.
|
||||
|
||||
## Hardening (`--hardening default`)
|
||||
|
||||
Every process that runs a container command gets, right before exec: `no_new_privs`, core dumps off, the capability bounding set reduced to the Docker default set (`CHOWN DAC_OVERRIDE FOWNER FSETID KILL SETGID SETUID SETPCAP NET_BIND_SERVICE NET_RAW SYS_CHROOT MKNOD AUDIT_WRITE SETFCAP`, mask `a80425fb`), and a seccomp-bpf filter: wrong architecture kills the process, x32 numbers get ENOSYS, `clone3` gets ENOSYS (libc falls back to `clone`), `clone`/`unshare` with `CLONE_NEWUSER` get EPERM, and these get EPERM: the mount family and `pivot_root`, `setns`, keyring calls, `bpf`, `perf_event_open`, `userfaultfd`, `io_uring_*`, `kexec_*`, module loading, `open_by_handle_at`, `process_vm_*`, `kcmp`, `reboot`, `swapon/off`, clock and time setting, `acct`, `quotactl` and old obsolete calls. Supported on x86_64 and aarch64; elsewhere only the capability drop applies, with a printed note. Tools that need the denied calls (container runtimes inside a container, Chromium's sandbox, io_uring users) need `--hardening relaxed`.
|
||||
|
||||
## Isolation
|
||||
|
||||
@@ -104,11 +122,13 @@ 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.
|
||||
- `host`: shares the host network namespace (no isolation, explicit opt-in).
|
||||
- `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
|
||||
|
||||
- `delete` (containers and images) is dry-run by default, scoped to the store, refuses running containers and used images, never follows symlinks. Trees with subuid-owned files are removed from inside the id map.
|
||||
- Unverifiable input is rejected: URL images without sha256, hash mismatches, bad names, bad state paths, missing slirp4netns or newuidmap when requested.
|
||||
- Seeding and scans report what they skipped (`unreadable skipped: N`).
|
||||
- A failed start kills the half-built container (no orphans); a failed image add leaves no partial image.
|
||||
- A failed start kills the half-built container (no orphans); a failed image add or container create leaves nothing behind.
|
||||
|
||||
+2
-1
@@ -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 <abi/5.0>,
|
||||
include <tunables/global>
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
+14
-10
@@ -7,11 +7,12 @@ How doos works internally. Behavior contract is `SPEC.md`; this explains the cod
|
||||
| File | Class / role |
|
||||
|---|---|
|
||||
| `src/spec.hpp` | `spec::` constants: caps, mount lists, device list, paths. Single source of truth with `SPEC.md`. |
|
||||
| `src/util.{hpp,cpp}` | `SysError`, `Fd` (RAII fd), `FileLock` (bounded flock), `parseSize`/`formatSize`, `runProcess` (fork/exec, capped capture), `fsutil` (mkdirs, read/write, bounded `removeTree`, `mirrorDirs` skeleton, `copyTree`, `copyFiles`, `diskUsage` with hardlink dedupe). |
|
||||
| `src/util.{hpp,cpp}` | `SysError`, `Fd` (RAII fd), `FileLock` (bounded flock), `parseSize`/`formatSize`, `base64Decode`, `runProcess` (fork/exec, capped capture), `fsutil` (mkdirs, read/write, bounded `removeTree`, `mirrorDirs` skeleton, `copyTree`/`copyEntries` (with skip predicate), `copyFiles`, `diskUsage` with hardlink dedupe). |
|
||||
| `src/idmap.{hpp,cpp}` | `IdMap` (single or subuid mapping via newuidmap/newgidmap, subuid range lookup), `UserNsJob` (run a function as root of a user namespace with the same map: image extraction, deleting subuid-owned trees). |
|
||||
| `src/image.{hpp,cpp}` | `Catalog` (alpine, ubuntu resolution from publishers), `ImageStore` (add with download, sha256 check, extraction; list; delete). |
|
||||
| `src/cgroup.{hpp,cpp}` | `Cgroup`: one cgroup v2 leaf per running container under the user's delegated cgroup, used for `cgroup.freeze`. |
|
||||
| `src/network.{hpp,cpp}` | `NetMode` + `Network` (parse, resolv.conf per mode), `SlirpSession` (owns the slirp4netns process). |
|
||||
| `src/image.{hpp,cpp}` | `Catalog` (alpine, ubuntu, debian resolution from publishers), `verifySignature` (gpg with a throwaway home, pinned fingerprint), `ImageStore` (add with download, sha256 and signature check, extraction; list; delete). |
|
||||
| `src/cgroup.{hpp,cpp}` | `Limits`, `Cgroup`: `doos/<name>/{main,shell}` under the user's delegated cgroup; limits on the parent, freeze on `main`, later attaches in `shell`, OOM and pid-limit counters. |
|
||||
| `src/hardening.{hpp,cpp}` | `Hardening::apply`: capability bounding set to the Docker default, seccomp-bpf denylist built at runtime (arch check, x32 guard, clone3 ENOSYS, CLONE_NEWUSER filter). |
|
||||
| `src/network.{hpp,cpp}` | `NetMode` (slirp, pasta, host, none) + `Network` (parse, resolv.conf per mode), `SlirpSession`, `PastaSession` (own the helper processes). |
|
||||
| `src/container.{hpp,cpp}` | `Options` (what the user asked for), `Container` (paths, meta, pid file, layout, one-time seeding, usage), `Store` (list, get_or_create with on-demand image fetch, remove). |
|
||||
| `src/sandbox.{hpp,cpp}` | `Sandbox`: start a container (clone + id map + mounts + pivot_root + init), watch it (size cap), or join a running one (setns). |
|
||||
| `src/cli.{hpp,cpp}` | `Cli`: argument parsing, the four container commands and `image list/add/delete`. |
|
||||
@@ -33,18 +34,21 @@ start():
|
||||
clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET]) child = container pid 1
|
||||
IdMap::apply(child, ids) single: write /proc maps; subuid: newuidmap + newgidmap
|
||||
slirp mode: SlirpSession.start(child) waits for ready-fd
|
||||
Cgroup::create + add(child) best effort; without it the size cap only warns
|
||||
slirp/pasta session for the netns of child
|
||||
Cgroup::create(limits) + addMain(child) best effort; without it nothing is enforced and doos says so
|
||||
recordPid(child) (pid + starttime, defeats pid reuse)
|
||||
lock.unlock() joiners must not wait for container lifetime
|
||||
write 'G' on sync pipe child continues
|
||||
parent: ignore SIGINT/SIGQUIT, forward SIGTERM/SIGHUP, watch(child)
|
||||
watch: poll(pidfd, 10 s); each wake scans usage -> warn 80%, freeze 100%, thaw <95%
|
||||
watch: poll(pidfd, until next scan); scan = usage from inside the id map (UserNsJob) -> warn 80%, freeze 100%,
|
||||
thaw <95%; next scan after max(2 s, 10 x scan time)
|
||||
after exit: report OOM kills / pid-limit hits
|
||||
on exit: pid file cleared, slirp stopped, cgroup removed (RAII)
|
||||
|
||||
child (containerInit, pid 1):
|
||||
PDEATHSIG=SIGKILL, close inherited lock fd, wait for 'G'
|
||||
setupRoot -> sethostname -> loopback up (isolated netns) -> rlimits/env/no_new_privs
|
||||
fork worker -> exec command
|
||||
fork worker -> applyLimitsAndEnv (RLIMIT_FSIZE = size cap, Hardening::apply unless relaxed) -> exec command
|
||||
pid 1 loop: forward signals, reap all, on worker exit kill(-1, SIGKILL), exit with its code
|
||||
```
|
||||
|
||||
@@ -74,7 +78,7 @@ unmount of the old root.
|
||||
`seeded.<name>` marker so it runs once and prints what it did and what it skipped:
|
||||
1. Directory skeleton of every overlaid host dir into its upper layer (`fsutil::mirrorDirs`).
|
||||
2. Regular files of `/var/lib/dpkg`, `triggers`, `alternatives` copied into the upper (`copyFiles`), then empty lock files.
|
||||
3. `/etc` copied into `private/etc` (`copyTree`).
|
||||
3. `/etc` copied into `private/etc`: the allowlist (`copyEntries` with the credential deny predicate) under default hardening, everything readable (`copyTree`) under relaxed.
|
||||
|
||||
Reasons are in `SPEC.md` (host root-owned files appear as `nobody`).
|
||||
|
||||
@@ -88,8 +92,8 @@ missing, and pins the container's id mode to the image's.
|
||||
|
||||
## join flow
|
||||
|
||||
Open `/proc/<pid>/ns/{user,mnt,uts,ipc,net,pid}` and `/proc/<pid>/root` first, fork,
|
||||
`setns` user first (grants capability for the rest), `fchdir`+`chroot` to the container
|
||||
Open `/proc/<pid>/ns/{user,mnt,uts,ipc,net,pid}` and `/proc/<pid>/root` first, fork, move the child into the
|
||||
container's `shell` cgroup (same limits, never frozen), `setns` user first (grants capability for the rest), `fchdir`+`chroot` to the container
|
||||
root, fork again (pid ns applies to children), apply env and limits, exec.
|
||||
In `host` network mode the net namespace is not joined.
|
||||
|
||||
|
||||
+62
-6
@@ -48,12 +48,12 @@ removed afterwards. Rows marked "unmeasured" have no run behind them.
|
||||
|
||||
| Id | Gap | Fix | Status | Code |
|
||||
|---|---|---|---|---|
|
||||
| G1 | Memory/cpu/pids limits | Cgroup v2 leaf under the delegated user service | **partly done in 1.0.0**: the leaf exists and is used for `cgroup.freeze`; `memory.max`, `pids.max`, `cpu.max` are not set yet | `cgroup.cpp` |
|
||||
| G2 | Disk cap on upper dirs | Usage watchdog with freeze | **done in 1.0.0** (`--size`) | `Sandbox::watch` |
|
||||
| G1 | Memory/cpu/pids limits | Cgroup v2 limits on `doos/<name>` | **done in 1.1.0** (`--memory`, `--pids`, `--cpus`) | `cgroup.cpp` |
|
||||
| G2 | Disk cap on upper dirs | Usage watchdog with freeze | **done in 1.0.0**, accounting and overshoot improved in 1.1.0 | `Sandbox::watch` |
|
||||
| G3 | Single uid mapping | `newuidmap`/`newgidmap` with `/etc/subuid`, fallback single | **done in 1.0.0** (`--ids`, default subuid) | `idmap.cpp`, `Store::remove` |
|
||||
| G4 | Host `/etc` readable | Allowlist-only private `/etc` | **open** for host mode: `/etc` is now a private copy of all readable files (stable, still world-readable secrets); **closed in image mode** (no host files) | `container.cpp` |
|
||||
| G5 | `--net host` shares abstract sockets | Keep opt-in; evaluate pasta | **open** | `network.cpp` |
|
||||
| G6 | No seccomp, nested userns allowed | Hand-built seccomp-bpf denylist | **open** | new `Seccomp` class |
|
||||
| G4 | Host `/etc` readable | Allowlist-only private `/etc` | **done in 1.1.0** for host mode (`--hardening relaxed` restores the full copy); image mode never had host files | `container.cpp` |
|
||||
| G5 | `--net host` shares abstract sockets | pasta mode | **done in 1.1.0** (`--net pasta`); `host` stays as an explicit, noted opt-in | `network.cpp` |
|
||||
| G6 | No seccomp, nested userns allowed | Capability drop + seccomp-bpf denylist | **done in 1.1.0** (`--hardening`) | `hardening.cpp` |
|
||||
| G7 | Host-owned lower dirs unwritable, `chown` of host files fails, empty `/var` | Image layer + subuid; skeleton and seeding for host mode | **done in 1.0.0** | `image.cpp`, `container.cpp` |
|
||||
|
||||
### G1: memory, cpu and pids limits (cgroup v2)
|
||||
@@ -210,7 +210,7 @@ uid 1000, subuid range 100000:65536). The end-to-end suite (`tests/run.sh`) pass
|
||||
|
||||
Subuid mapping added about 15 to 25 ms per start compared with 0.1.x (two setuid helper processes). Creating a host-mode container (skeleton, `/etc` copy, dpkg files) took about 1.2 s once. First `--image alpine` use including download and extraction: 0.3 s end to end on this network.
|
||||
|
||||
### Still open, stated plainly
|
||||
### Still open after 1.0.0 (see "Resolution log (1.1.0)" for what changed since)
|
||||
|
||||
- G1: `memory.max`, `pids.max`, `cpu.max` are not set. Measured earlier that they work here; not wired in.
|
||||
- G4: host mode copies every readable file of host `/etc` at creation. Use an image to avoid host files entirely.
|
||||
@@ -222,3 +222,59 @@ Subuid mapping added about 15 to 25 ms per start compared with 0.1.x (two setuid
|
||||
- Ubuntu image signature (`SHA256SUMS.gpg`) is not verified; Alpine's yaml is not signature-checked either.
|
||||
- `image add FILE` without `--sha256` is accepted and prints a note, since the user chose the file.
|
||||
- Catalog covers x86_64 and aarch64 only; aarch64 is untested.
|
||||
|
||||
## Resolution log (1.1.0)
|
||||
|
||||
Built and measured on the same machine (Ubuntu 26.04, kernel 7.0.0-38, 12 cores, ext4, uid 1000, cgroup v2
|
||||
delegated with `cpuset cpu io memory pids`). The end-to-end suite passed 83 checks.
|
||||
|
||||
### Research behind each decision
|
||||
|
||||
| Question | Finding | Source | Decision |
|
||||
|---|---|---|---|
|
||||
| Which capabilities to keep | Docker's default set: CHOWN DAC_OVERRIDE FSETID FOWNER MKNOD NET_RAW SETGID SETUID SETFCAP SETPCAP NET_BIND_SERVICE SYS_CHROOT KILL AUDIT_WRITE | [Docker run reference via OneUptime](https://oneuptime.com/blog/post/2026-01-25-docker-container-capabilities/markdown), [Red Hat container security guide](https://access.redhat.com/documentation/en/red_hat_enterprise_linux_atomic_host/7/html/container_security_guide/linux_capabilities_and_seccomp) | Same set. Measured: `CapEff` inside is `a80425fb`, the Docker default mask. |
|
||||
| Which syscalls to deny | Docker's profile blocks keyring calls (not namespaced) and io_uring (container escapes); kexec and bpf are also capability-gated | [Docker seccomp docs](https://docs.docker.com/engine/security/seccomp/) | Denylist in `hardening.cpp`; capability drop covers the capability-gated ones twice. |
|
||||
| x32 and architecture | A denylist keyed on the x86-64 arch can be dodged with `__X32_SYSCALL_BIT`; kernels >= 5.4 answer such numbers with ENOSYS | [seccomp(2) man page patch](https://lkml.iu.edu/hypermail/linux/kernel/2007.1/03947.html) | Arch check first (kill on mismatch), x32 numbers answered ENOSYS. |
|
||||
| `clone3` | Flags live in a struct seccomp cannot read; ENOSYS (not EPERM) makes glibc fall back to `clone` | [QEMU seccomp patch](https://qemu.googlesource.com/qemu/+/c542b302707628609f2a33bcbd3e442282806b8e), [libc-alpha thread](https://public-inbox.org/libc-alpha/20210727102222.r2hys526mfkpt4xo@senku/) | `clone3` -> ENOSYS, `clone`/`unshare` with `CLONE_NEWUSER` -> EPERM. Measured both. |
|
||||
| cgroup layout | Delegation covers a subtree; limit files exist only when the parent enables the controller; a cgroup that has children cannot hold processes | [kernel cgroup v2 docs](https://docs.kernel.org/admin-guide/cgroup-v2.html), [rootlesscontaine.rs](https://rootlesscontaine.rs/getting-started/common/cgroup2/) | `doos/` enables `+memory +pids +cpu`; limits on `doos/<name>` (no processes); leaves `main` and `shell`. |
|
||||
| Host-like networking without sharing the namespace | pasta attaches to an existing namespace by PID and splices local TCP/UDP between namespaces | [passt(1) man page](https://manpages.ubuntu.com/manpages/resolute/man1/passt.1.html) | `--net pasta`. Measured below. |
|
||||
| Image signatures | Alpine publishes the release key fingerprint on its downloads page; Ubuntu publishes the CD image key fingerprint in its verification tutorial | [alpinelinux.org/downloads](https://alpinelinux.org/downloads/), [ubuntu.com verify tutorial](https://ubuntu.com/tutorials/how-to-verify-ubuntu) | Fingerprints pinned in `spec.hpp`; keys fetched at install and refused unless the fingerprint matches. |
|
||||
| Debian rootfs | No `rootfs.tar.xz` served; the OCI layer is at `oci/blobs/rootfs.tar.gz` and its digest is in the manifest embedded (base64) in `oci/index.json` | measured with curl against `debuerreotype/docker-debian-artifacts` | `debian`, `debian-RELEASE` in the catalog; the digest is the integrity check. |
|
||||
|
||||
### Measurements
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Capabilities | `CapEff a80425fb`, `Seccomp: 2` (filter) inside a default container; relaxed: `CapEff 000001ffffffffff`, `Seccomp: 0` |
|
||||
| seccomp | `keyctl`, `bpf`, `mount`, `unshare(CLONE_NEWUSER)` -> EPERM; `clone3` -> ENOSYS; `unshare -U` and `mount -t tmpfs` fail; relaxed container runs `unshare -U` fine |
|
||||
| Tools under hardening | gcc, curl https, git, python3 `ssl`, ping, apt and apk installs all still work |
|
||||
| `/etc` | 50 entries under the allowlist versus 215 in a relaxed copy; no `shadow`, `sudoers`, `ssh_host_*` |
|
||||
| Memory | `--memory 256m`, touching 400 MB: whole container killed, doos prints `killed by its memory limit` |
|
||||
| Pids | `--pids 32`, 80 forks: `can't fork`, doos prints `hit its process limit (32)` |
|
||||
| CPU | 4 busy loops for 4.3 s wall: unlimited 16.2 CPU-seconds, `--cpus 0.5` 2.3 CPU-seconds (0.54 of one core) |
|
||||
| pasta | host `127.0.0.1` listener reachable, host abstract unix socket blocked (host mode: reachable), outbound TCP, DNS and https 200, inbound port forwarded to the host |
|
||||
| Signatures | Alpine and Ubuntu 24.04 verified with the pinned keys; a stand-in `gpg` that reports a valid signature by another key, or `BADSIG`, is refused and leaves no image |
|
||||
| Debian | `debian` (trixie) image installs and `apt-get install cowsay` completes, `dpkg --audit` clean; image 82M |
|
||||
| Size accounting | 80 MB written by container uid 5 in a mode-700 directory (invisible to the host user) against `--size 64m`: frozen; cleanup from a second attach thawed it |
|
||||
| Attach time (5 runs) | host or image, slirp: about 40 ms each; pasta: about 65 ms; `--net none`: about 10 ms |
|
||||
|
||||
### What changed in behavior (and why it is not a loss)
|
||||
|
||||
- Hardening can be switched off per container (`--hardening relaxed`), so nothing that needs the denied calls is lost, it is opt-in now.
|
||||
- Host `/etc` content outside the allowlist is absent under default hardening. `relaxed` restores the full copy; images never had it.
|
||||
- The 10 s scan interval is gone: scans start every 2 s at the earliest but are paced by their own cost, so large trees are not scanned in a tight loop.
|
||||
- pasta replaces the need for `--net host` in almost every case; `host` remains.
|
||||
|
||||
### Still open, stated plainly
|
||||
|
||||
- Memory limits count `/tmp` and `/dev/shm` (both RAM-backed) and page cache charged to the container; a build that fills `/tmp` can hit `--memory`. Measured only with anonymous memory.
|
||||
- `--pids` counts threads. Thread-heavy programs need a higher value.
|
||||
- `memory.high` is not used, so there is no soft throttle before the OOM kill.
|
||||
- Size accounting is a scan, not a quota: writes can pass the cap by up to one scan interval, bounded per file by `RLIMIT_FSIZE`. A process can still write many small files faster than a scan.
|
||||
- A later `doos attach` shares the memory, pids and cpu limits and the per-file size limit, but is never frozen. That is the rescue path by design, so it can also fill the disk past the cap.
|
||||
- Host-mode containers still drift from the host (snapshots of `/etc`, dpkg state, skeleton). Images do not.
|
||||
- Debian images have no publisher signature to check; trust is HTTPS to the Debian Docker maintainers' repository plus the content digest. The Alpine key is pinned from alpinelinux.org, not from a second out-of-band channel beyond the fingerprint shown on the same site and in public mailing lists.
|
||||
- aarch64 paths (Alpine `aarch64`, Debian `dist-arm64v8`, seccomp numbers for aarch64) compile and are guarded but were not run: no aarch64 machine here.
|
||||
- Without `gpg` installed, signature checks are skipped with a printed note.
|
||||
- pasta is not signalled by doos (it drops uid); it exits by itself within about a second after the container's namespace is gone. Measured: no pasta process remained after 1.5 s.
|
||||
- seccomp is a denylist. A new kernel syscall is allowed until it is added to the list.
|
||||
|
||||
+108
-22
@@ -3,6 +3,7 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <sstream>
|
||||
#include <thread>
|
||||
|
||||
#include "spec.hpp"
|
||||
@@ -12,55 +13,140 @@ namespace fs = std::filesystem;
|
||||
|
||||
namespace doos {
|
||||
|
||||
std::unique_ptr<Cgroup> Cgroup::create(const std::string& name, std::string& whyNot) {
|
||||
namespace {
|
||||
bool hasController(const fs::path& dir, const std::string& name) {
|
||||
try {
|
||||
std::string self = fsutil::readFile("/proc/self/cgroup");
|
||||
std::string marker = "user@" + std::to_string(::geteuid()) + ".service";
|
||||
auto at = self.find(marker);
|
||||
if (self.rfind("0::", 0) != 0 || at == std::string::npos) {
|
||||
whyNot = "no cgroup v2 delegated to the user manager (" + marker + " not found)";
|
||||
std::istringstream in(fsutil::readFile(dir / "cgroup.controllers"));
|
||||
for (std::string c; in >> c;) {
|
||||
if (c == name) return true;
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool tryWrite(const fs::path& file, const std::string& text) {
|
||||
try {
|
||||
fsutil::writeFile(file, text);
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
fs::path Cgroup::base(std::string& whyNot) {
|
||||
std::string self;
|
||||
try {
|
||||
self = fsutil::readFile("/proc/self/cgroup");
|
||||
} catch (const std::exception& e) {
|
||||
whyNot = e.what();
|
||||
return {};
|
||||
}
|
||||
std::string marker = "user@" + std::to_string(::geteuid()) + ".service";
|
||||
auto at = self.find(marker);
|
||||
if (self.rfind("0::", 0) != 0 || at == std::string::npos) {
|
||||
whyNot = "no cgroup v2 delegated to the user manager (" + marker + " not found)";
|
||||
return {};
|
||||
}
|
||||
return fs::path("/sys/fs/cgroup") / self.substr(3, at + marker.size() - 3).substr(1);
|
||||
}
|
||||
|
||||
std::unique_ptr<Cgroup> Cgroup::create(const std::string& name, const Limits& limits, std::string& whyNot,
|
||||
std::string& notEnforced) {
|
||||
try {
|
||||
fs::path b = base(whyNot);
|
||||
if (b.empty()) return nullptr;
|
||||
fs::path top = b / std::string(spec::kCgroupDir);
|
||||
std::error_code ec;
|
||||
fs::create_directory(top, ec);
|
||||
if (ec && ec != std::errc::file_exists) {
|
||||
whyNot = "cannot create " + top.string() + ": " + ec.message();
|
||||
return nullptr;
|
||||
}
|
||||
fs::path base = fs::path("/sys/fs/cgroup") / self.substr(3, at + marker.size() - 3).substr(1);
|
||||
fs::path dir = base / std::string(spec::kCgroupDir);
|
||||
std::error_code ec;
|
||||
// Controllers must be enabled for children of `doos/` for the limit files to exist in `doos/<name>`.
|
||||
std::string enable;
|
||||
for (const char* c : {"memory", "pids", "cpu"}) {
|
||||
if (hasController(b, c)) enable += std::string(" +") + c;
|
||||
}
|
||||
if (!enable.empty()) tryWrite(top / "cgroup.subtree_control", enable);
|
||||
|
||||
fs::path dir = top / name;
|
||||
fs::create_directory(dir, ec);
|
||||
if (ec && ec != std::errc::file_exists) {
|
||||
whyNot = "cannot create " + dir.string() + ": " + ec.message();
|
||||
return nullptr;
|
||||
}
|
||||
fs::path leaf = dir / name;
|
||||
fs::create_directory(leaf, ec);
|
||||
if (ec && ec != std::errc::file_exists) {
|
||||
whyNot = "cannot create " + leaf.string() + ": " + ec.message();
|
||||
for (const char* leaf : {"main", "shell"}) fs::create_directory(dir / leaf, ec);
|
||||
if (::access((dir / "main" / "cgroup.freeze").c_str(), W_OK) != 0) {
|
||||
whyNot = "cgroup.freeze not writable in " + dir.string();
|
||||
return nullptr;
|
||||
}
|
||||
if (::access((leaf / "cgroup.freeze").c_str(), W_OK) != 0) {
|
||||
whyNot = "cgroup.freeze not writable in " + leaf.string();
|
||||
fs::remove(leaf, ec);
|
||||
return nullptr;
|
||||
auto note = [&](const std::string& what) { notEnforced += (notEnforced.empty() ? "" : ", ") + what; };
|
||||
if (limits.memoryBytes) {
|
||||
if (!tryWrite(dir / "memory.max", std::to_string(limits.memoryBytes))) note("memory");
|
||||
else {
|
||||
tryWrite(dir / "memory.swap.max", "0"); // otherwise the cap can be dodged through swap
|
||||
tryWrite(dir / "memory.oom.group", "1"); // an OOM kill takes the whole container, not one process
|
||||
}
|
||||
}
|
||||
return std::unique_ptr<Cgroup>(new Cgroup(leaf));
|
||||
if (limits.pids && !tryWrite(dir / "pids.max", std::to_string(limits.pids))) note("pids");
|
||||
if (limits.milliCpus) {
|
||||
long quota = static_cast<long>(limits.milliCpus) * spec::kCpuPeriodUs / 1000;
|
||||
if (!tryWrite(dir / "cpu.max", std::to_string(quota) + " " + std::to_string(spec::kCpuPeriodUs))) note("cpus");
|
||||
}
|
||||
return std::unique_ptr<Cgroup>(new Cgroup(dir, true));
|
||||
} catch (const std::exception& e) {
|
||||
whyNot = e.what();
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
std::unique_ptr<Cgroup> Cgroup::open(const std::string& name) {
|
||||
std::string why;
|
||||
fs::path b = base(why);
|
||||
if (b.empty()) return nullptr;
|
||||
fs::path dir = b / std::string(spec::kCgroupDir) / name;
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(dir / "shell", ec)) return nullptr;
|
||||
return std::unique_ptr<Cgroup>(new Cgroup(dir, false));
|
||||
}
|
||||
|
||||
Cgroup::~Cgroup() {
|
||||
// Processes die with the container; give the kernel a moment to empty the group, then remove it.
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
if (!owner_) return;
|
||||
// Everything dies with the container's init; kill any straggler, then remove the leaves and the group.
|
||||
tryWrite(dir_ / "cgroup.kill", "1");
|
||||
for (int i = 0; i < 40; ++i) {
|
||||
std::error_code ec;
|
||||
fs::remove(dir_ / "main", ec);
|
||||
fs::remove(dir_ / "shell", ec);
|
||||
if (fs::remove(dir_, ec)) return;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
}
|
||||
}
|
||||
|
||||
void Cgroup::add(pid_t pid) const { fsutil::writeFile(dir_ / "cgroup.procs", std::to_string(pid)); }
|
||||
void Cgroup::addMain(pid_t pid) const { fsutil::writeFile(dir_ / "main" / "cgroup.procs", std::to_string(pid)); }
|
||||
void Cgroup::addShell(pid_t pid) const { fsutil::writeFile(dir_ / "shell" / "cgroup.procs", std::to_string(pid)); }
|
||||
|
||||
void Cgroup::freeze(bool on) const {
|
||||
fsutil::writeFile(dir_ / "cgroup.freeze", on ? "1" : "0");
|
||||
fsutil::writeFile(dir_ / "main" / "cgroup.freeze", on ? "1" : "0");
|
||||
frozen_ = on;
|
||||
}
|
||||
|
||||
std::uint64_t Cgroup::counter(const char* file, const char* key) const {
|
||||
try {
|
||||
std::istringstream in(fsutil::readFile(dir_ / file));
|
||||
std::string k;
|
||||
std::uint64_t v;
|
||||
while (in >> k >> v) {
|
||||
if (k == key) return v;
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::uint64_t Cgroup::oomKills() const { return counter("memory.events", "oom_kill"); }
|
||||
std::uint64_t Cgroup::pidFailures() const { return counter("pids.events", "max"); }
|
||||
|
||||
} // namespace doos
|
||||
|
||||
+27
-6
@@ -1,29 +1,50 @@
|
||||
#pragma once
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace doos {
|
||||
|
||||
// One cgroup v2 leaf per running container, under the cgroup delegated to the user's systemd manager.
|
||||
// Used to freeze a container that hit its size cap (freezing needs no controller). Best effort:
|
||||
// create() returns null when delegation is absent, and the caller must say so.
|
||||
struct Limits {
|
||||
std::uint64_t memoryBytes = 0; // 0 = not limited
|
||||
std::uint32_t pids = 0;
|
||||
std::uint32_t milliCpus = 0; // 0 = all cores
|
||||
};
|
||||
|
||||
// cgroup v2 subtree of one running container under the cgroup delegated to the user's systemd manager:
|
||||
//
|
||||
// doos/<name>/ limits live here (memory.max, pids.max, cpu.max), no processes
|
||||
// main/ the container's init and everything it starts; frozen when the size cap is hit
|
||||
// shell/ processes started by later `doos attach` calls; never frozen (rescue path), same limits
|
||||
//
|
||||
// Best effort: create() returns null when delegation is absent, and the caller must say why.
|
||||
class Cgroup {
|
||||
public:
|
||||
static std::unique_ptr<Cgroup> create(const std::string& name, std::string& whyNot);
|
||||
static std::unique_ptr<Cgroup> create(const std::string& name, const Limits& limits, std::string& whyNot,
|
||||
std::string& notEnforced);
|
||||
// Existing group of a running container, for joiners. Null when none.
|
||||
static std::unique_ptr<Cgroup> open(const std::string& name);
|
||||
~Cgroup();
|
||||
Cgroup(const Cgroup&) = delete;
|
||||
Cgroup& operator=(const Cgroup&) = delete;
|
||||
|
||||
void add(pid_t pid) const;
|
||||
void addMain(pid_t pid) const;
|
||||
void addShell(pid_t pid) const;
|
||||
void freeze(bool on) const;
|
||||
bool frozen() const { return frozen_; }
|
||||
// Number of OOM kills and of fork failures at the pids limit since creation.
|
||||
std::uint64_t oomKills() const;
|
||||
std::uint64_t pidFailures() const;
|
||||
|
||||
private:
|
||||
explicit Cgroup(std::filesystem::path dir) : dir_(std::move(dir)) {}
|
||||
Cgroup(std::filesystem::path dir, bool owner) : dir_(std::move(dir)), owner_(owner) {}
|
||||
static std::filesystem::path base(std::string& whyNot);
|
||||
std::uint64_t counter(const char* file, const char* key) const;
|
||||
std::filesystem::path dir_;
|
||||
bool owner_;
|
||||
mutable bool frozen_ = false;
|
||||
};
|
||||
|
||||
|
||||
+50
-7
@@ -15,6 +15,9 @@ namespace doos {
|
||||
|
||||
namespace {
|
||||
|
||||
std::uint32_t parseCount(const std::string& text);
|
||||
std::uint32_t parseCpus(const std::string& text);
|
||||
|
||||
// Leading --options, then the first positional, then everything else untouched.
|
||||
struct Parsed {
|
||||
Options opts;
|
||||
@@ -47,6 +50,14 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
||||
else if (allow.container && is(a, "--image")) p.opts.image = value(i, "--image");
|
||||
else if (allow.container && is(a, "--size")) p.opts.size = parseSize(value(i, "--size"));
|
||||
else if (allow.container && is(a, "--ids")) p.opts.ids = IdMap::parse(value(i, "--ids"));
|
||||
else if (allow.container && is(a, "--memory")) p.opts.memory = parseSize(value(i, "--memory"));
|
||||
else if (allow.container && is(a, "--pids")) p.opts.pids = parseCount(value(i, "--pids"));
|
||||
else if (allow.container && is(a, "--cpus")) p.opts.milliCpus = parseCpus(value(i, "--cpus"));
|
||||
else if (allow.container && is(a, "--hardening")) {
|
||||
std::string v = value(i, "--hardening");
|
||||
if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed");
|
||||
p.opts.relaxed = (v == "relaxed");
|
||||
}
|
||||
else if (allow.yes && a == "--yes") p.yes = true;
|
||||
else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256");
|
||||
else if (a.size() > 1 && a[0] == '-') throw std::runtime_error("unknown option " + a);
|
||||
@@ -59,6 +70,26 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
||||
return p;
|
||||
}
|
||||
|
||||
std::uint32_t parseCount(const std::string& text) {
|
||||
if (text.empty() || text.size() > 9 || text.find_first_not_of("0123456789") != std::string::npos) {
|
||||
throw std::runtime_error("invalid number '" + text + "'");
|
||||
}
|
||||
return static_cast<std::uint32_t>(std::stoul(text));
|
||||
}
|
||||
|
||||
// "2", "0.5", "1.25" cpus -> thousandths; "0" means all cores.
|
||||
std::uint32_t parseCpus(const std::string& text) {
|
||||
std::size_t dot = text.find('.');
|
||||
std::string whole = text.substr(0, dot), frac = dot == std::string::npos ? "" : text.substr(dot + 1);
|
||||
if (whole.empty() || whole.size() > 5 || frac.size() > 3 ||
|
||||
whole.find_first_not_of("0123456789") != std::string::npos ||
|
||||
frac.find_first_not_of("0123456789") != std::string::npos) {
|
||||
throw std::runtime_error("invalid cpus '" + text + "' (examples: 2, 0.5, 1.25, 0 for all cores)");
|
||||
}
|
||||
while (frac.size() < 3) frac += '0';
|
||||
return static_cast<std::uint32_t>(std::stoul(whole)) * 1000 + static_cast<std::uint32_t>(std::stoul(frac));
|
||||
}
|
||||
|
||||
std::string age(long created) {
|
||||
long d = std::time(nullptr) - created;
|
||||
if (d < 120) return std::to_string(d) + "s";
|
||||
@@ -81,14 +112,20 @@ 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 (fixed at creation, except --size):\n"
|
||||
" --image NAME start from an image instead of the host system (fetched on demand)\n"
|
||||
" --size SIZE writable-layer cap, 64m..1t, default 4g (e.g. 512m, 8g)\n"
|
||||
" --net slirp|host|none network mode, default slirp\n"
|
||||
" --ids single|subuid id mapping, default: subuid when available\n"
|
||||
"options:\n"
|
||||
" --image NAME start from an image instead of the host system (fetched on demand) [fixed]\n"
|
||||
" --net slirp|pasta|host|none network, default slirp (pasta: host-like, private sockets) [fixed]\n"
|
||||
" --ids single|subuid id mapping, default: subuid when available [fixed]\n"
|
||||
" --hardening default|relaxed relaxed = no capability drop, no seccomp, full /etc [fixed]\n"
|
||||
" --size SIZE writable-layer cap, 64m..1t, default 4g\n"
|
||||
" --memory SIZE memory cap, 64m..1t, default 8g\n"
|
||||
" --pids N process cap, 16..4194304, default 4096\n"
|
||||
" --cpus N cpu cap, 0.1..1024, default 0 (all cores)\n"
|
||||
" size, memory, pids and cpus can be changed on any later call; they apply at the next start.\n"
|
||||
"\n"
|
||||
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, an https URL (needs --sha256), or a tar file.\n"
|
||||
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, an https URL (needs --sha256), or a tar file.\n"
|
||||
"attach creates NAME when missing. delete only reports unless --yes is given.\n"
|
||||
"State lives in $DOOS_HOME or ~/.local/share/doos.\n",
|
||||
out);
|
||||
@@ -108,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;
|
||||
@@ -151,6 +189,11 @@ int Cli::cmdDelete(std::vector<std::string> rest) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Cli::cmdApparmor() {
|
||||
std::fputs(Sandbox::apparmorProfile().c_str(), stdout);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int Cli::cmdImage(std::vector<std::string> rest) {
|
||||
if (rest.empty()) throw std::runtime_error("image needs a subcommand: list, add, delete");
|
||||
std::string sub = rest[0];
|
||||
@@ -194,7 +237,7 @@ int Cli::cmdImageAdd(std::vector<std::string> rest) {
|
||||
src.sha256 = *p.sha256;
|
||||
} else {
|
||||
if (!std::filesystem::is_regular_file(source)) {
|
||||
throw std::runtime_error("'" + source + "' is neither a catalog image (alpine, ubuntu, ubuntu-VERSION), an https URL, nor a file");
|
||||
throw std::runtime_error("'" + source + "' is neither a catalog image (alpine, ubuntu, ubuntu-VERSION, debian, debian-RELEASE), an https URL, nor a file");
|
||||
}
|
||||
src.location = std::filesystem::absolute(source).string();
|
||||
src.isFile = true;
|
||||
|
||||
@@ -15,6 +15,7 @@ private:
|
||||
int cmdGetOrCreate(std::vector<std::string> rest);
|
||||
int cmdAttach(std::vector<std::string> rest);
|
||||
int cmdDelete(std::vector<std::string> rest);
|
||||
int cmdApparmor();
|
||||
int cmdImage(std::vector<std::string> rest);
|
||||
int cmdImageList();
|
||||
int cmdImageAdd(std::vector<std::string> rest);
|
||||
|
||||
+115
-10
@@ -1,6 +1,8 @@
|
||||
#include "container.hpp"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
@@ -82,6 +84,23 @@ void Container::seed(const std::string& marker, const std::string& label,
|
||||
fsutil::writeFile(m, "ok\n");
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Names that may hold credentials are never copied, even inside allowed directories.
|
||||
bool etcDenied(const fs::path& p) {
|
||||
std::string n = p.filename().string();
|
||||
if (n.size() > 4 && n.compare(n.size() - 4, 4, ".key") == 0) return true;
|
||||
for (auto d : spec::kEtcDenyNames) {
|
||||
std::string pat(d);
|
||||
if (!pat.empty() && pat.back() == '*') {
|
||||
if (n.rfind(pat.substr(0, pat.size() - 1), 0) == 0) return true;
|
||||
} else if (n == pat) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void Container::prepareLayout() const {
|
||||
fsutil::mkdirs(rootfs());
|
||||
if (imageMode()) {
|
||||
@@ -154,8 +173,17 @@ void Container::prepareLayout() const {
|
||||
for (auto d : spec::kCopiedDirs) {
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(std::string(d), ec)) continue;
|
||||
seed("copy-" + std::string(slug(d)), "copy of " + std::string(d),
|
||||
[&] { return fsutil::copyTree(std::string(d), privateDir(std::string(d))); });
|
||||
std::string key = slug(d);
|
||||
std::replace(key.begin(), key.end(), '/', '_');
|
||||
if (relaxed_) {
|
||||
seed("copy-" + key, "copy of " + std::string(d) + " (relaxed)",
|
||||
[&] { return fsutil::copyTree(std::string(d), privateDir(std::string(d))); });
|
||||
} else {
|
||||
seed("copy-" + key, "allowlisted " + std::string(d), [&] {
|
||||
std::vector<std::string> names(spec::kEtcAllow.begin(), spec::kEtcAllow.end());
|
||||
return fsutil::copyEntries(std::string(d), privateDir(std::string(d)), names, etcDenied);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,15 +205,40 @@ std::uint64_t Container::usedBytes(std::size_t& skipped) const {
|
||||
std::error_code ec;
|
||||
if (fs::exists(dir_ / "layers", ec)) findUppers(dir_ / "layers", 0, dirs);
|
||||
if (fs::exists(dir_ / "private", ec)) dirs.push_back(dir_ / "private");
|
||||
std::uint64_t total = 0;
|
||||
for (const auto& d : dirs) total += fsutil::diskUsage(d, skipped);
|
||||
return total;
|
||||
auto scan = [&](std::size_t& sk) {
|
||||
std::uint64_t total = 0;
|
||||
for (const auto& d : dirs) total += fsutil::diskUsage(d, sk);
|
||||
return total;
|
||||
};
|
||||
if (ids_ == IdMode::Single) return scan(skipped);
|
||||
// Files of other container ids (host uids 100000+) and their mode 700 directories are invisible to the host
|
||||
// user; measuring as root of the id map sees them.
|
||||
int p[2];
|
||||
if (::pipe2(p, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
Fd rd(p[0]), wr(p[1]);
|
||||
int rc = UserNsJob::run(ids_, [&] {
|
||||
std::size_t sk = 0;
|
||||
std::uint64_t t = scan(sk);
|
||||
::dprintf(wr.get(), "%llu %zu\n", static_cast<unsigned long long>(t), sk);
|
||||
return 0;
|
||||
});
|
||||
wr.reset();
|
||||
if (rc != 0) throw std::runtime_error("size scan failed");
|
||||
char buf[64] = {0};
|
||||
if (::read(rd.get(), buf, sizeof buf - 1) <= 0) throw std::runtime_error("size scan returned nothing");
|
||||
unsigned long long t = 0;
|
||||
std::size_t sk = 0;
|
||||
std::sscanf(buf, "%llu %zu", &t, &sk);
|
||||
skipped += sk;
|
||||
return t;
|
||||
}
|
||||
|
||||
void Container::writeMeta() const {
|
||||
fsutil::writeFile(metaFile(), "net=" + Network::name(net_) + "\ncreated=" + std::to_string(created_) +
|
||||
"\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) +
|
||||
"\nsize=" + std::to_string(size_) + "\n");
|
||||
"\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) +
|
||||
"\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) +
|
||||
"\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n");
|
||||
}
|
||||
|
||||
void Container::setSizeCap(std::uint64_t bytes) {
|
||||
@@ -197,11 +250,41 @@ void Container::setSizeCap(std::uint64_t bytes) {
|
||||
writeMeta();
|
||||
}
|
||||
|
||||
void Container::create(const NetMode& net, const std::string& image, IdMode ids, std::uint64_t size) {
|
||||
void Container::setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids,
|
||||
std::optional<std::uint32_t> milliCpus) {
|
||||
if (memory) {
|
||||
if (*memory < spec::kMinMemoryBytes || *memory > spec::kMaxMemoryBytes) {
|
||||
throw std::runtime_error("memory must be between " + formatSize(spec::kMinMemoryBytes) + " and " +
|
||||
formatSize(spec::kMaxMemoryBytes));
|
||||
}
|
||||
memory_ = *memory;
|
||||
}
|
||||
if (pids) {
|
||||
if (*pids < spec::kMinPids || *pids > spec::kMaxPids) {
|
||||
throw std::runtime_error("pids must be between " + std::to_string(spec::kMinPids) + " and " +
|
||||
std::to_string(spec::kMaxPids));
|
||||
}
|
||||
pids_ = *pids;
|
||||
}
|
||||
if (milliCpus) {
|
||||
if (*milliCpus != 0 && (*milliCpus < spec::kMinMilliCpus || *milliCpus > spec::kMaxMilliCpus)) {
|
||||
throw std::runtime_error("cpus must be between 0.1 and 1024 (0 = all cores)");
|
||||
}
|
||||
milliCpus_ = *milliCpus;
|
||||
}
|
||||
writeMeta();
|
||||
}
|
||||
|
||||
void Container::create(const NetMode& net, const std::string& image, IdMode ids, const Options& o) {
|
||||
net_ = net;
|
||||
image_ = image;
|
||||
ids_ = ids;
|
||||
size_ = size;
|
||||
size_ = o.size.value_or(spec::kDefaultSizeBytes);
|
||||
relaxed_ = o.relaxed.value_or(false);
|
||||
memory_ = spec::kDefaultMemoryBytes;
|
||||
pids_ = spec::kDefaultPids;
|
||||
milliCpus_ = 0;
|
||||
if (o.memory || o.pids || o.milliCpus) setLimits(o.memory, o.pids, o.milliCpus);
|
||||
created_ = std::time(nullptr);
|
||||
fsutil::mkdirs(dir_);
|
||||
prepareLayout();
|
||||
@@ -220,6 +303,10 @@ void Container::readMeta() {
|
||||
image_ = kv.count("image") ? kv["image"] : "";
|
||||
ids_ = IdMap::parse(kv.count("ids") ? kv["ids"] : "single");
|
||||
size_ = kv.count("size") ? std::stoull(kv["size"]) : spec::kDefaultSizeBytes;
|
||||
memory_ = kv.count("memory") ? std::stoull(kv["memory"]) : spec::kDefaultMemoryBytes;
|
||||
pids_ = kv.count("pids") ? static_cast<std::uint32_t>(std::stoul(kv["pids"])) : spec::kDefaultPids;
|
||||
milliCpus_ = kv.count("cpus") ? static_cast<std::uint32_t>(std::stoul(kv["cpus"])) : 0;
|
||||
relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed";
|
||||
}
|
||||
|
||||
std::optional<pid_t> Container::runningPid() const {
|
||||
@@ -307,7 +394,9 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
if (opts.net) fixed("net", Network::name(c.net()), Network::name(*opts.net));
|
||||
if (image) fixed("image", c.image().empty() ? "none" : c.image(), *image);
|
||||
if (opts.ids) fixed("ids", IdMap::name(c.ids()), IdMap::name(*opts.ids));
|
||||
if (opts.relaxed) fixed("hardening", c.relaxed() ? "relaxed" : "default", *opts.relaxed ? "relaxed" : "default");
|
||||
if (opts.size && *opts.size != c.sizeCap()) c.setSizeCap(*opts.size);
|
||||
if (opts.memory || opts.pids || opts.milliCpus) c.setLimits(opts.memory, opts.pids, opts.milliCpus);
|
||||
};
|
||||
|
||||
if (fs::exists(dir / "meta", ec)) {
|
||||
@@ -329,7 +418,7 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
ImageStore store = images();
|
||||
if (!store.has(*image)) {
|
||||
if (!Catalog::knows(*image)) {
|
||||
throw std::runtime_error("unknown image '" + *image + "'. Add it: doos image add SOURCE [NAME] (catalog: alpine, ubuntu)");
|
||||
throw std::runtime_error("unknown image '" + *image + "'. Add it: doos image add SOURCE [NAME] (catalog: alpine, ubuntu, debian)");
|
||||
}
|
||||
std::fprintf(stderr, "doos: image '%s' not present, fetching it\n", image->c_str());
|
||||
store.add(Catalog::resolve(*image), ids);
|
||||
@@ -353,7 +442,23 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
||||
verifyExisting();
|
||||
return {c, false};
|
||||
}
|
||||
c.create(opts.net.value_or(NetMode::Slirp), image.value_or(""), ids, size);
|
||||
Options resolved = opts;
|
||||
resolved.size = size;
|
||||
try {
|
||||
c.create(opts.net.value_or(NetMode::Slirp), image.value_or(""), ids, resolved);
|
||||
} catch (...) {
|
||||
// a container that failed halfway must not stay behind as an unreadable directory
|
||||
try {
|
||||
if (ids == IdMode::Subuid) UserNsJob::run(ids, [&] { fsutil::removeTree(dir); return 0; });
|
||||
else fsutil::removeTree(dir);
|
||||
} catch (...) {
|
||||
}
|
||||
throw;
|
||||
}
|
||||
if (c.net() == NetMode::Host) {
|
||||
std::fprintf(stderr, "doos: note: --net host shares the host's abstract unix sockets and loopback; "
|
||||
"--net pasta gives the same reachability in a private namespace\n");
|
||||
}
|
||||
return {c, true};
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -21,8 +21,13 @@ struct Options {
|
||||
std::optional<std::string> image;
|
||||
std::optional<IdMode> ids;
|
||||
std::optional<std::uint64_t> size;
|
||||
std::optional<std::uint64_t> memory;
|
||||
std::optional<std::uint32_t> pids;
|
||||
std::optional<std::uint32_t> milliCpus; // 0 = all cores
|
||||
std::optional<bool> relaxed; // --hardening relaxed
|
||||
};
|
||||
|
||||
|
||||
class Container {
|
||||
public:
|
||||
Container(std::filesystem::path dir, std::string name) : dir_(std::move(dir)), name_(std::move(name)) {}
|
||||
@@ -47,8 +52,14 @@ public:
|
||||
IdMode ids() const { return ids_; }
|
||||
std::uint64_t sizeCap() const { return size_; }
|
||||
void setSizeCap(std::uint64_t bytes);
|
||||
std::uint64_t memoryCap() const { return memory_; }
|
||||
std::uint32_t pidsCap() const { return pids_; }
|
||||
std::uint32_t milliCpus() const { return milliCpus_; }
|
||||
bool relaxed() const { return relaxed_; }
|
||||
// Limits take effect the next time the container starts.
|
||||
void setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids, std::optional<std::uint32_t> milliCpus);
|
||||
|
||||
void create(const NetMode& net, const std::string& image, IdMode ids, std::uint64_t size);
|
||||
void create(const NetMode& net, const std::string& image, IdMode ids, const Options& resolved);
|
||||
void readMeta(); // meta only, no side effects
|
||||
void load(); // readMeta + heal the layout
|
||||
|
||||
@@ -60,6 +71,7 @@ public:
|
||||
// Mountpoints, layer dirs, and one-time seeding (skeleton, /etc copy). Idempotent.
|
||||
void prepareLayout() const;
|
||||
// Bytes the container wrote (writable layers and private dirs), as the host user can see them.
|
||||
// Measured from inside the id map, so directories of other container ids are counted too.
|
||||
std::uint64_t usedBytes(std::size_t& skipped) const;
|
||||
|
||||
private:
|
||||
@@ -73,6 +85,10 @@ private:
|
||||
std::string image_;
|
||||
IdMode ids_ = IdMode::Single;
|
||||
std::uint64_t size_ = 0;
|
||||
std::uint64_t memory_ = 0;
|
||||
std::uint32_t pids_ = 0;
|
||||
std::uint32_t milliCpus_ = 0;
|
||||
bool relaxed_ = false;
|
||||
};
|
||||
|
||||
class Store {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
#include "hardening.hpp"
|
||||
|
||||
#include <linux/audit.h>
|
||||
#include <linux/filter.h>
|
||||
#include <linux/seccomp.h>
|
||||
#include <sched.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstddef>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
#include "spec.hpp"
|
||||
#include "util.hpp"
|
||||
|
||||
#ifndef SECCOMP_RET_KILL_PROCESS
|
||||
#define SECCOMP_RET_KILL_PROCESS 0x80000000U
|
||||
#endif
|
||||
#ifndef CAP_LAST_CAP_FALLBACK
|
||||
#define CAP_LAST_CAP_FALLBACK 40
|
||||
#endif
|
||||
|
||||
namespace doos {
|
||||
|
||||
namespace {
|
||||
|
||||
#if defined(__x86_64__)
|
||||
constexpr unsigned kArch = AUDIT_ARCH_X86_64;
|
||||
constexpr bool kHasX32 = true;
|
||||
#elif defined(__aarch64__)
|
||||
constexpr unsigned kArch = AUDIT_ARCH_AARCH64;
|
||||
constexpr bool kHasX32 = false;
|
||||
#else
|
||||
constexpr unsigned kArch = 0;
|
||||
constexpr bool kHasX32 = false;
|
||||
#endif
|
||||
|
||||
// Syscalls answered with EPERM. Entries the architecture does not have are skipped at compile time.
|
||||
std::vector<unsigned> deniedSyscalls() {
|
||||
std::vector<unsigned> v;
|
||||
#define DENY(name) v.push_back(static_cast<unsigned>(name))
|
||||
#ifdef SYS_acct
|
||||
DENY(SYS_acct);
|
||||
#endif
|
||||
#ifdef SYS_add_key
|
||||
DENY(SYS_add_key);
|
||||
#endif
|
||||
#ifdef SYS_bpf
|
||||
DENY(SYS_bpf);
|
||||
#endif
|
||||
#ifdef SYS_clock_adjtime
|
||||
DENY(SYS_clock_adjtime);
|
||||
#endif
|
||||
#ifdef SYS_clock_settime
|
||||
DENY(SYS_clock_settime);
|
||||
#endif
|
||||
#ifdef SYS_create_module
|
||||
DENY(SYS_create_module);
|
||||
#endif
|
||||
#ifdef SYS_delete_module
|
||||
DENY(SYS_delete_module);
|
||||
#endif
|
||||
#ifdef SYS_finit_module
|
||||
DENY(SYS_finit_module);
|
||||
#endif
|
||||
#ifdef SYS_init_module
|
||||
DENY(SYS_init_module);
|
||||
#endif
|
||||
#ifdef SYS_get_kernel_syms
|
||||
DENY(SYS_get_kernel_syms);
|
||||
#endif
|
||||
#ifdef SYS_keyctl
|
||||
DENY(SYS_keyctl);
|
||||
#endif
|
||||
#ifdef SYS_kexec_file_load
|
||||
DENY(SYS_kexec_file_load);
|
||||
#endif
|
||||
#ifdef SYS_kexec_load
|
||||
DENY(SYS_kexec_load);
|
||||
#endif
|
||||
#ifdef SYS_lookup_dcookie
|
||||
DENY(SYS_lookup_dcookie);
|
||||
#endif
|
||||
#ifdef SYS_nfsservctl
|
||||
DENY(SYS_nfsservctl);
|
||||
#endif
|
||||
#ifdef SYS_open_by_handle_at
|
||||
DENY(SYS_open_by_handle_at);
|
||||
#endif
|
||||
#ifdef SYS_perf_event_open
|
||||
DENY(SYS_perf_event_open);
|
||||
#endif
|
||||
#ifdef SYS_pivot_root
|
||||
DENY(SYS_pivot_root);
|
||||
#endif
|
||||
#ifdef SYS_process_vm_readv
|
||||
DENY(SYS_process_vm_readv);
|
||||
#endif
|
||||
#ifdef SYS_process_vm_writev
|
||||
DENY(SYS_process_vm_writev);
|
||||
#endif
|
||||
#ifdef SYS_query_module
|
||||
DENY(SYS_query_module);
|
||||
#endif
|
||||
#ifdef SYS_quotactl
|
||||
DENY(SYS_quotactl);
|
||||
#endif
|
||||
#ifdef SYS_reboot
|
||||
DENY(SYS_reboot);
|
||||
#endif
|
||||
#ifdef SYS_request_key
|
||||
DENY(SYS_request_key);
|
||||
#endif
|
||||
#ifdef SYS_settimeofday
|
||||
DENY(SYS_settimeofday);
|
||||
#endif
|
||||
#ifdef SYS_stime
|
||||
DENY(SYS_stime);
|
||||
#endif
|
||||
#ifdef SYS_swapon
|
||||
DENY(SYS_swapon);
|
||||
#endif
|
||||
#ifdef SYS_swapoff
|
||||
DENY(SYS_swapoff);
|
||||
#endif
|
||||
#ifdef SYS_sysfs
|
||||
DENY(SYS_sysfs);
|
||||
#endif
|
||||
#ifdef SYS__sysctl
|
||||
DENY(SYS__sysctl);
|
||||
#endif
|
||||
#ifdef SYS_uselib
|
||||
DENY(SYS_uselib);
|
||||
#endif
|
||||
#ifdef SYS_userfaultfd
|
||||
DENY(SYS_userfaultfd);
|
||||
#endif
|
||||
#ifdef SYS_ustat
|
||||
DENY(SYS_ustat);
|
||||
#endif
|
||||
#ifdef SYS_mount
|
||||
DENY(SYS_mount);
|
||||
#endif
|
||||
#ifdef SYS_umount2
|
||||
DENY(SYS_umount2);
|
||||
#endif
|
||||
#ifdef SYS_umount
|
||||
DENY(SYS_umount);
|
||||
#endif
|
||||
#ifdef SYS_mount_setattr
|
||||
DENY(SYS_mount_setattr);
|
||||
#endif
|
||||
#ifdef SYS_move_mount
|
||||
DENY(SYS_move_mount);
|
||||
#endif
|
||||
#ifdef SYS_open_tree
|
||||
DENY(SYS_open_tree);
|
||||
#endif
|
||||
#ifdef SYS_fsopen
|
||||
DENY(SYS_fsopen);
|
||||
#endif
|
||||
#ifdef SYS_fsconfig
|
||||
DENY(SYS_fsconfig);
|
||||
#endif
|
||||
#ifdef SYS_fsmount
|
||||
DENY(SYS_fsmount);
|
||||
#endif
|
||||
#ifdef SYS_fspick
|
||||
DENY(SYS_fspick);
|
||||
#endif
|
||||
#ifdef SYS_io_uring_setup
|
||||
DENY(SYS_io_uring_setup);
|
||||
#endif
|
||||
#ifdef SYS_io_uring_enter
|
||||
DENY(SYS_io_uring_enter);
|
||||
#endif
|
||||
#ifdef SYS_io_uring_register
|
||||
DENY(SYS_io_uring_register);
|
||||
#endif
|
||||
#ifdef SYS_setns
|
||||
DENY(SYS_setns);
|
||||
#endif
|
||||
#ifdef SYS_kcmp
|
||||
DENY(SYS_kcmp);
|
||||
#endif
|
||||
#undef DENY
|
||||
return v;
|
||||
}
|
||||
|
||||
sock_filter stmt(unsigned short code, unsigned k) { return BPF_STMT(code, k); }
|
||||
sock_filter jump(unsigned short code, unsigned k, unsigned char jt, unsigned char jf) { return BPF_JUMP(code, k, jt, jf); }
|
||||
|
||||
constexpr unsigned kOffNr = offsetof(seccomp_data, nr);
|
||||
constexpr unsigned kOffArch = offsetof(seccomp_data, arch);
|
||||
constexpr unsigned kOffArg0 = offsetof(seccomp_data, args); // low 32 bits on little-endian
|
||||
|
||||
std::vector<sock_filter> buildFilter() {
|
||||
const unsigned eperm = SECCOMP_RET_ERRNO | EPERM;
|
||||
const unsigned enosys = SECCOMP_RET_ERRNO | ENOSYS;
|
||||
std::vector<sock_filter> f;
|
||||
// wrong architecture: kill, never guess
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffArch));
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, kArch, 1, 0));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS));
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffNr));
|
||||
if (kHasX32) { // x32 syscall numbers must not dodge the numbers below
|
||||
f.push_back(jump(BPF_JMP | BPF_JSET | BPF_K, 0x40000000U, 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, enosys));
|
||||
}
|
||||
// clone3 cannot be inspected (flags live in a struct): ENOSYS makes libc fall back to clone()
|
||||
#ifdef SYS_clone3
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, static_cast<unsigned>(SYS_clone3), 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, enosys));
|
||||
#endif
|
||||
// clone/unshare with CLONE_NEWUSER: EPERM (nested user namespaces are the usual first step of kernel exploits)
|
||||
for (unsigned nr : {static_cast<unsigned>(SYS_clone), static_cast<unsigned>(SYS_unshare)}) {
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 4));
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffArg0));
|
||||
f.push_back(jump(BPF_JMP | BPF_JSET | BPF_K, CLONE_NEWUSER, 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, eperm));
|
||||
f.push_back(stmt(BPF_LD | BPF_W | BPF_ABS, kOffNr)); // restore the accumulator for the next compare
|
||||
}
|
||||
for (unsigned nr : deniedSyscalls()) {
|
||||
f.push_back(jump(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 1));
|
||||
f.push_back(stmt(BPF_RET | BPF_K, eperm));
|
||||
}
|
||||
f.push_back(stmt(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||
if (f.size() > 4096) throw std::runtime_error("seccomp program too large");
|
||||
return f;
|
||||
}
|
||||
|
||||
void dropCapabilities() {
|
||||
int last = CAP_LAST_CAP_FALLBACK;
|
||||
std::ifstream in("/proc/sys/kernel/cap_last_cap");
|
||||
if (in) in >> last;
|
||||
for (int cap = 0; cap <= last; ++cap) {
|
||||
if (std::find(spec::kKeptCaps.begin(), spec::kKeptCaps.end(), cap) != spec::kKeptCaps.end()) continue;
|
||||
if (::prctl(PR_CAPBSET_DROP, cap, 0, 0, 0) != 0 && errno != EINVAL) {
|
||||
throw SysError("drop capability " + std::to_string(cap));
|
||||
}
|
||||
}
|
||||
::prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Hardening::apply() {
|
||||
dropCapabilities();
|
||||
if (kArch == 0) {
|
||||
std::fprintf(stderr, "doos: no seccomp filter for this CPU architecture, capabilities were still reduced\n");
|
||||
return;
|
||||
}
|
||||
std::vector<sock_filter> prog = buildFilter();
|
||||
sock_fprog fp{static_cast<unsigned short>(prog.size()), prog.data()};
|
||||
if (::prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &fp) != 0) throw SysError("install seccomp filter");
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
namespace doos {
|
||||
|
||||
// Confinement applied to every process that runs a container command, right before exec:
|
||||
// 1. capability bounding set reduced to the Docker default set (spec::kKeptCaps),
|
||||
// 2. a seccomp-bpf denylist (keyring, bpf, perf, userfaultfd, io_uring, mount family, kexec, module loading,
|
||||
// nested user namespaces and the like), with an architecture check and x32 guard.
|
||||
// Both need no_new_privs, which the caller sets. Throws when the filter cannot be installed.
|
||||
class Hardening {
|
||||
public:
|
||||
static void apply();
|
||||
};
|
||||
|
||||
} // namespace doos
|
||||
+119
-4
@@ -1,5 +1,8 @@
|
||||
#include "image.hpp"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <sys/stat.h>
|
||||
#include <cstdio>
|
||||
#include <sys/utsname.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -35,6 +38,81 @@ std::string fetchText(const std::string& url) {
|
||||
return r.out;
|
||||
}
|
||||
|
||||
void fetchFile(const std::string& url, const fs::path& out) {
|
||||
ProcResult r = runProcess({"curl", "--fail", "--silent", "--show-error", "--location", "--proto", "=https",
|
||||
"--max-time", "60", "--max-filesize", std::to_string(spec::kMaxCaptureBytes),
|
||||
"--output", out.string(), url});
|
||||
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||
}
|
||||
|
||||
// Private scratch directory, removed on scope exit.
|
||||
class TempDir {
|
||||
public:
|
||||
TempDir() {
|
||||
std::string tmpl = (fs::temp_directory_path() / "doos-XXXXXX").string();
|
||||
if (!::mkdtemp(tmpl.data())) throw SysError("mkdtemp");
|
||||
path_ = tmpl;
|
||||
}
|
||||
~TempDir() {
|
||||
std::error_code ec;
|
||||
fs::remove_all(path_, ec);
|
||||
}
|
||||
TempDir(const TempDir&) = delete;
|
||||
TempDir& operator=(const TempDir&) = delete;
|
||||
const fs::path& path() const { return path_; }
|
||||
|
||||
private:
|
||||
fs::path path_;
|
||||
};
|
||||
|
||||
std::string upper(std::string s) {
|
||||
for (auto& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
// Verifies a detached signature against a publisher key that is fetched over https and accepted only when its
|
||||
// fingerprint equals the pinned one. Returns false (with a loud note) when gpg is not installed; throws when the
|
||||
// signature is bad, missing, or made by another key.
|
||||
bool verifySignature(const fs::path& data, const fs::path& sig, const std::string& keyUrl, const std::string& fpr) {
|
||||
if (runProcess({"sh", "-c", "command -v gpg"}, true, true).code != 0) {
|
||||
std::fprintf(stderr, "note: signature NOT checked (gpg is not installed); relying on https and the publisher's checksum\n");
|
||||
return false;
|
||||
}
|
||||
TempDir home;
|
||||
::chmod(home.path().c_str(), 0700);
|
||||
fs::path keyFile = home.path() / "key.asc";
|
||||
fetchFile(keyUrl, keyFile);
|
||||
std::string h = home.path().string();
|
||||
if (runProcess({"gpg", "--batch", "--quiet", "--homedir", h, "--import", keyFile.string()}, false, true).code != 0) {
|
||||
throw std::runtime_error("cannot import the publisher key from " + keyUrl);
|
||||
}
|
||||
ProcResult fp = runProcess({"gpg", "--batch", "--homedir", h, "--with-colons", "--fingerprint"}, true, true);
|
||||
if (fp.out.find(":::::::::" + upper(fpr) + ":") == std::string::npos) {
|
||||
throw std::runtime_error("the key at " + keyUrl + " does not have the pinned fingerprint " + fpr);
|
||||
}
|
||||
ProcResult v = runProcess({"gpg", "--batch", "--homedir", h, "--status-fd", "1", "--verify", sig.string(), data.string()}, true, true);
|
||||
bool good = false, valid = false;
|
||||
std::istringstream in(v.out);
|
||||
for (std::string line; std::getline(in, line);) {
|
||||
if (line.find("BADSIG") != std::string::npos || line.find("ERRSIG") != std::string::npos ||
|
||||
line.find("EXPKEYSIG") != std::string::npos || line.find("REVKEYSIG") != std::string::npos) {
|
||||
throw std::runtime_error("bad signature on " + data.filename().string());
|
||||
}
|
||||
if (line.find("[GNUPG:] GOODSIG") == 0) good = true;
|
||||
if (line.find("[GNUPG:] VALIDSIG") == 0) {
|
||||
std::istringstream ls(line);
|
||||
std::vector<std::string> tok;
|
||||
for (std::string t; ls >> t;) tok.push_back(upper(t));
|
||||
if (tok.size() > 2 && (tok[2] == upper(fpr) || tok.back() == upper(fpr))) valid = true;
|
||||
}
|
||||
}
|
||||
if (v.code != 0 || !good || !valid) {
|
||||
throw std::runtime_error("signature check failed for " + data.filename().string() + " (not signed by " + fpr + ")");
|
||||
}
|
||||
std::fprintf(stderr, "signature verified (key %s)\n", fpr.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isHex64(const std::string& s) {
|
||||
return s.size() == 64 && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isxdigit(c); });
|
||||
}
|
||||
@@ -53,10 +131,11 @@ std::string lowerCase(std::string s) {
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> Catalog::names() { return {"alpine", "ubuntu", "ubuntu-24.04"}; }
|
||||
std::vector<std::string> Catalog::names() { return {"alpine", "ubuntu", "ubuntu-24.04", "debian"}; }
|
||||
|
||||
bool Catalog::knows(const std::string& n) {
|
||||
return n == "alpine" || n == "ubuntu" || (n.rfind("ubuntu-", 0) == 0 && n.size() > 7);
|
||||
return n == "alpine" || n == "ubuntu" || (n.rfind("ubuntu-", 0) == 0 && n.size() > 7) || n == "debian" ||
|
||||
(n.rfind("debian-", 0) == 0 && n.size() > 7);
|
||||
}
|
||||
|
||||
ImageSource Catalog::resolve(const std::string& n) {
|
||||
@@ -84,6 +163,32 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
}
|
||||
src.location = base + file;
|
||||
src.sha256 = sha;
|
||||
src.sigUrl = src.location + ".asc";
|
||||
src.keyUrl = std::string(spec::kAlpineKeyUrl);
|
||||
src.keyFpr = std::string(spec::kAlpineKeyFpr);
|
||||
return src;
|
||||
}
|
||||
if (n == "debian" || n.rfind("debian-", 0) == 0) {
|
||||
std::string rel = n == "debian" ? std::string(spec::kDebianDefaultRelease) : n.substr(7);
|
||||
if (rel.empty() || !std::all_of(rel.begin(), rel.end(), [](unsigned char c) { return std::islower(c); })) {
|
||||
throw std::runtime_error("invalid debian release '" + rel + "' (example: debian-bookworm)");
|
||||
}
|
||||
std::string arch = cpuArch(true);
|
||||
std::string dist = arch == "amd64" ? "dist-amd64" : "dist-arm64v8";
|
||||
std::string base = std::string(spec::kDebianBase) + dist + "/" + rel + "/slim/oci/";
|
||||
std::string index = fetchText(base + "index.json");
|
||||
// The manifest is embedded (base64) in the index; its single layer digest is the sha256 of the rootfs tarball.
|
||||
auto d = index.find("\"data\":\"");
|
||||
if (d == std::string::npos) throw std::runtime_error("unexpected debian index.json (no embedded manifest)");
|
||||
d += 8;
|
||||
std::string manifest = base64Decode(index.substr(d, index.find('"', d) - d));
|
||||
auto l = manifest.find("\"layers\"");
|
||||
auto dg = l == std::string::npos ? l : manifest.find("sha256:", l);
|
||||
if (dg == std::string::npos) throw std::runtime_error("unexpected debian manifest (no layer digest)");
|
||||
std::string sha = manifest.substr(dg + 7, 64);
|
||||
if (!isHex64(sha)) throw std::runtime_error("unexpected debian layer digest");
|
||||
src.location = base + "blobs/rootfs.tar.gz";
|
||||
src.sha256 = sha; // content-addressed: the digest is the integrity check; there is no publisher signature
|
||||
return src;
|
||||
}
|
||||
if (n == "ubuntu" || n.rfind("ubuntu-", 0) == 0) {
|
||||
@@ -93,7 +198,12 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
}
|
||||
std::string arch = cpuArch(true);
|
||||
std::string base = std::string(spec::kUbuntuBase) + ver + "/release/";
|
||||
std::string sums = fetchText(base + "SHA256SUMS");
|
||||
TempDir tmp;
|
||||
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
|
||||
fetchFile(base + "SHA256SUMS.gpg", tmp.path() / "SHA256SUMS.gpg");
|
||||
verifySignature(tmp.path() / "SHA256SUMS", tmp.path() / "SHA256SUMS.gpg", std::string(spec::kUbuntuKeyUrl),
|
||||
std::string(spec::kUbuntuKeyFpr));
|
||||
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
|
||||
std::string suffix = "-base-" + arch + ".tar.gz";
|
||||
std::string bestFile, bestSha;
|
||||
std::vector<unsigned long> bestVer;
|
||||
@@ -114,7 +224,7 @@ ImageSource Catalog::resolve(const std::string& n) {
|
||||
src.sha256 = bestSha;
|
||||
return src;
|
||||
}
|
||||
throw std::runtime_error("unknown catalog image '" + n + "' (known: alpine, ubuntu, ubuntu-VERSION)");
|
||||
throw std::runtime_error("unknown catalog image '" + n + "' (known: alpine, ubuntu, ubuntu-VERSION, debian, debian-RELEASE)");
|
||||
}
|
||||
|
||||
std::string ImageStore::normalize(const std::string& name) {
|
||||
@@ -214,6 +324,11 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
||||
throw std::runtime_error("sha256 mismatch for " + src.location + ": expected " + src.sha256 + ", got " + got);
|
||||
}
|
||||
std::fprintf(stderr, "sha256 verified\n");
|
||||
if (!src.sigUrl.empty()) {
|
||||
TempDir sigDir;
|
||||
fetchFile(src.sigUrl, sigDir.path() / "file.asc");
|
||||
verifySignature(archive, sigDir.path() / "file.asc", src.keyUrl, src.keyFpr);
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr, "note: local file used without --sha256, not verified\n");
|
||||
}
|
||||
|
||||
+6
-1
@@ -15,6 +15,10 @@ struct ImageSource {
|
||||
std::string location; // https URL or local file path
|
||||
std::string sha256; // expected, empty only for local files
|
||||
bool isFile = false;
|
||||
// Optional detached signature over the downloaded file; the key is fetched and must match the pinned fingerprint.
|
||||
std::string sigUrl;
|
||||
std::string keyUrl;
|
||||
std::string keyFpr;
|
||||
};
|
||||
|
||||
struct ImageInfo {
|
||||
@@ -25,7 +29,8 @@ struct ImageInfo {
|
||||
std::uint64_t bytes = 0;
|
||||
};
|
||||
|
||||
// Built-in sources: alpine, ubuntu (latest LTS), ubuntu-VERSION. Resolved from the publishers over https.
|
||||
// Built-in sources: alpine, ubuntu (latest LTS), ubuntu-VERSION, debian, debian-RELEASE. Resolved from the
|
||||
// publishers over https; Alpine and Ubuntu are also checked against their pinned release signing keys.
|
||||
class Catalog {
|
||||
public:
|
||||
static std::vector<std::string> names();
|
||||
|
||||
+50
-2
@@ -18,14 +18,16 @@ namespace doos {
|
||||
|
||||
NetMode Network::parse(const std::string& s) {
|
||||
if (s == "slirp") return NetMode::Slirp;
|
||||
if (s == "pasta") return NetMode::Pasta;
|
||||
if (s == "host") return NetMode::Host;
|
||||
if (s == "none") return NetMode::None;
|
||||
throw std::runtime_error("unknown network mode '" + s + "' (use slirp, host or none)");
|
||||
throw std::runtime_error("unknown network mode '" + s + "' (use slirp, pasta, host or none)");
|
||||
}
|
||||
|
||||
std::string Network::name(NetMode m) {
|
||||
switch (m) {
|
||||
case NetMode::Slirp: return "slirp";
|
||||
case NetMode::Pasta: return "pasta";
|
||||
case NetMode::Host: return "host";
|
||||
case NetMode::None: return "none";
|
||||
}
|
||||
@@ -36,8 +38,10 @@ std::string Network::resolvConf(NetMode m) {
|
||||
switch (m) {
|
||||
case NetMode::Slirp: return "nameserver " + std::string(spec::kSlirpDns) + "\n";
|
||||
case NetMode::None: return "";
|
||||
case NetMode::Pasta:
|
||||
case NetMode::Host: {
|
||||
// Host netns is shared, so the host resolver config is valid. Stub on 127.0.0.53 works too.
|
||||
// pasta forwards the namespace's loopback to the host and host mode shares it, so the host resolver
|
||||
// config is valid as is, including the 127.0.0.53 stub.
|
||||
std::ifstream in("/etc/resolv.conf");
|
||||
return std::string(std::istreambuf_iterator<char>(in), {});
|
||||
}
|
||||
@@ -84,3 +88,47 @@ void SlirpSession::stop() {
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
namespace doos {
|
||||
|
||||
void PastaSession::start(pid_t targetPid) {
|
||||
pid_ = ::fork();
|
||||
if (pid_ < 0) throw SysError("fork pasta");
|
||||
if (pid_ == 0) {
|
||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||
int nul = ::open("/dev/null", O_WRONLY);
|
||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||
std::string target = std::to_string(targetPid);
|
||||
::execlp("pasta", "pasta", "--foreground", "--quiet", "--config-net", target.c_str(), static_cast<char*>(nullptr));
|
||||
::_exit(127);
|
||||
}
|
||||
// pasta has no ready fd: wait until the target namespace has an interface besides lo.
|
||||
std::string dev = "/proc/" + std::to_string(targetPid) + "/net/dev";
|
||||
for (int waited = 0; waited < spec::kNetReadyTimeoutMs; waited += 20) {
|
||||
int st = 0;
|
||||
if (::waitpid(pid_, &st, WNOHANG) == pid_) {
|
||||
pid_ = -1;
|
||||
throw std::runtime_error("pasta exited early (is it installed and permitted?)");
|
||||
}
|
||||
std::ifstream in(dev);
|
||||
int lines = 0;
|
||||
for (std::string line; std::getline(in, line);) ++lines;
|
||||
if (lines > 3) return; // two header lines + lo + at least one more interface
|
||||
::poll(nullptr, 0, 20);
|
||||
}
|
||||
stop();
|
||||
throw std::runtime_error("pasta not ready in time");
|
||||
}
|
||||
|
||||
void PastaSession::stop() {
|
||||
if (pid_ <= 0) return;
|
||||
// pasta drops to another uid after start, so we cannot signal it, and it leaves by itself shortly after its
|
||||
// namespace is gone. Reap it if it is already done; otherwise let init adopt it instead of blocking the exit.
|
||||
for (int waited = 0; waited < 20; waited += 5) {
|
||||
if (::waitpid(pid_, nullptr, WNOHANG) == pid_) break;
|
||||
::poll(nullptr, 0, 5);
|
||||
}
|
||||
pid_ = -1;
|
||||
}
|
||||
|
||||
} // namespace doos
|
||||
|
||||
+20
-1
@@ -5,7 +5,12 @@
|
||||
|
||||
namespace doos {
|
||||
|
||||
enum class NetMode { Slirp, Host, None };
|
||||
// slirp: private netns, outbound only, host loopback blocked (default).
|
||||
// pasta: private netns that behaves like the host network (host loopback, bound ports forwarded), but with
|
||||
// its own abstract unix sockets.
|
||||
// host: the host's network namespace itself (shares abstract sockets), explicit opt-in.
|
||||
// none: loopback only.
|
||||
enum class NetMode { Slirp, Pasta, Host, None };
|
||||
|
||||
class Network {
|
||||
public:
|
||||
@@ -17,6 +22,20 @@ public:
|
||||
static std::string resolvConf(NetMode m);
|
||||
};
|
||||
|
||||
// Owns a pasta process attached to a container's network namespace.
|
||||
class PastaSession {
|
||||
public:
|
||||
PastaSession() = default;
|
||||
PastaSession(const PastaSession&) = delete;
|
||||
PastaSession& operator=(const PastaSession&) = delete;
|
||||
~PastaSession() { stop(); }
|
||||
void start(pid_t targetPid);
|
||||
void stop();
|
||||
|
||||
private:
|
||||
pid_t pid_ = -1;
|
||||
};
|
||||
|
||||
// Owns a slirp4netns process attached to a container's network namespace.
|
||||
class SlirpSession {
|
||||
public:
|
||||
|
||||
+94
-10
@@ -15,12 +15,15 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
|
||||
#include <poll.h>
|
||||
#include <algorithm>
|
||||
|
||||
#include <chrono>
|
||||
|
||||
#include "cgroup.hpp"
|
||||
#include "hardening.hpp"
|
||||
#include "spec.hpp"
|
||||
#include "util.hpp"
|
||||
|
||||
@@ -200,12 +203,16 @@ void applyLimitsAndEnv(const Container& c) {
|
||||
if (::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) throw SysError("no_new_privs");
|
||||
rlimit core{0, 0};
|
||||
::setrlimit(RLIMIT_CORE, &core);
|
||||
// No single file can outgrow the size cap, which bounds the overshoot between two usage scans.
|
||||
rlimit fsize{c.sizeCap(), c.sizeCap()};
|
||||
::setrlimit(RLIMIT_FSIZE, &fsize);
|
||||
rlimit nofile;
|
||||
if (::getrlimit(RLIMIT_NOFILE, &nofile) == 0) {
|
||||
if (nofile.rlim_max > spec::kMaxOpenFiles) nofile.rlim_max = spec::kMaxOpenFiles;
|
||||
if (nofile.rlim_cur > nofile.rlim_max) nofile.rlim_cur = nofile.rlim_max;
|
||||
::setrlimit(RLIMIT_NOFILE, &nofile);
|
||||
}
|
||||
if (!c.relaxed()) Hardening::apply();
|
||||
const char* term = std::getenv("TERM");
|
||||
std::string termv = term && *term ? term : "xterm";
|
||||
const char* lang = std::getenv("LANG");
|
||||
@@ -287,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<const SysError*>(&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 <abi/5.0>,\n"
|
||||
"include <tunables/global>\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.
|
||||
@@ -340,12 +395,20 @@ int Sandbox::start(FileLock& lock) {
|
||||
IdMap::apply(child, container_.ids());
|
||||
|
||||
SlirpSession slirp;
|
||||
PastaSession pasta;
|
||||
if (net == NetMode::Slirp) slirp.start(child);
|
||||
if (net == NetMode::Pasta) pasta.start(child);
|
||||
|
||||
std::string cgroupWhy;
|
||||
std::unique_ptr<Cgroup> cgroup = Cgroup::create(container_.name(), cgroupWhy);
|
||||
if (cgroup) cgroup->add(child);
|
||||
else std::fprintf(stderr, "doos: size cap only warns, cannot freeze: %s\n", cgroupWhy.c_str());
|
||||
Limits limits{container_.memoryCap(), container_.pidsCap(), container_.milliCpus()};
|
||||
std::string cgroupWhy, notEnforced;
|
||||
std::unique_ptr<Cgroup> cgroup = Cgroup::create(container_.name(), limits, cgroupWhy, notEnforced);
|
||||
if (cgroup) {
|
||||
cgroup->addMain(child);
|
||||
if (!notEnforced.empty()) std::fprintf(stderr, "doos: limits not enforced (controller missing): %s\n", notEnforced.c_str());
|
||||
} else {
|
||||
std::fprintf(stderr, "doos: memory, pids and cpu limits are not enforced and the size cap only warns: %s\n",
|
||||
cgroupWhy.c_str());
|
||||
}
|
||||
|
||||
container_.recordPid(child);
|
||||
struct PidGuard {
|
||||
@@ -369,34 +432,50 @@ int Sandbox::start(FileLock& lock) {
|
||||
|
||||
int status = watch(child, cgroup.get());
|
||||
reaper.armed = false;
|
||||
if (cgroup && cgroup->oomKills() > 0) {
|
||||
std::fprintf(stderr, "doos: '%s' was killed by its memory limit (%s); raise it with --memory\n",
|
||||
container_.name().c_str(), formatSize(container_.memoryCap()).c_str());
|
||||
}
|
||||
if (cgroup && cgroup->pidFailures() > 0) {
|
||||
std::fprintf(stderr, "doos: '%s' hit its process limit (%u) %llu times; raise it with --pids\n",
|
||||
container_.name().c_str(), container_.pidsCap(),
|
||||
static_cast<unsigned long long>(cgroup->pidFailures()));
|
||||
}
|
||||
return exitCodeOf(status);
|
||||
}
|
||||
|
||||
// Waits for the container's init while enforcing the size cap: warn at kSizeWarnPercent, freeze at the cap,
|
||||
// thaw again once usage drops below kSizeThawPercent. Nothing is ever deleted by this loop.
|
||||
int Sandbox::watch(pid_t child, Cgroup* cgroup) {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
long fd = ::syscall(SYS_pidfd_open, child, 0);
|
||||
Fd pidfd(static_cast<int>(fd));
|
||||
const std::uint64_t cap = container_.sizeCap();
|
||||
bool warned = false;
|
||||
auto lastScan = std::chrono::steady_clock::now();
|
||||
auto nextScan = Clock::now() + std::chrono::milliseconds(spec::kSizeCheckMinIntervalMs);
|
||||
for (;;) {
|
||||
auto left = std::chrono::duration_cast<std::chrono::milliseconds>(nextScan - Clock::now()).count();
|
||||
int timeout = static_cast<int>(std::max<long>(left, 50));
|
||||
if (pidfd.valid()) {
|
||||
pollfd p{pidfd.get(), POLLIN, 0};
|
||||
int r = ::poll(&p, 1, spec::kSizeCheckIntervalMs);
|
||||
int r = ::poll(&p, 1, timeout);
|
||||
if (r > 0) break;
|
||||
if (r < 0 && errno != EINTR) break;
|
||||
} else {
|
||||
int st = 0;
|
||||
if (::waitpid(child, &st, WNOHANG) == child) return st;
|
||||
::poll(nullptr, 0, 1000);
|
||||
::poll(nullptr, 0, std::min(timeout, 1000));
|
||||
}
|
||||
if (g_terminating && cgroup && cgroup->frozen()) cgroup->freeze(false); // a frozen init cannot see SIGTERM
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
if (now - lastScan < std::chrono::milliseconds(spec::kSizeCheckIntervalMs)) continue;
|
||||
lastScan = now;
|
||||
if (Clock::now() < nextScan) continue;
|
||||
|
||||
auto began = Clock::now();
|
||||
std::size_t skipped = 0;
|
||||
std::uint64_t used = container_.usedBytes(skipped);
|
||||
// The scan may use at most 1/kSizeScanDutyFactor of the time: big trees are scanned less often.
|
||||
auto took = Clock::now() - began;
|
||||
nextScan = Clock::now() + std::max(std::chrono::milliseconds(spec::kSizeCheckMinIntervalMs),
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(took) * spec::kSizeScanDutyFactor);
|
||||
const char* name = container_.name().c_str();
|
||||
if (used >= cap) {
|
||||
if (cgroup && !cgroup->frozen()) {
|
||||
@@ -444,10 +523,15 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
|
||||
if (!rootFd.valid()) throw SysError("open container root");
|
||||
|
||||
lock.unlock();
|
||||
// Later attaches live in the `shell` leaf: same limits as the container, but never frozen.
|
||||
std::unique_ptr<Cgroup> cgroup = Cgroup::open(container_.name());
|
||||
pid_t child = ::fork();
|
||||
if (child < 0) throw SysError("fork");
|
||||
if (child == 0) {
|
||||
try {
|
||||
if (cgroup) {
|
||||
try { cgroup->addShell(::getpid()); } catch (const std::exception&) { /* limits only; keep going */ }
|
||||
}
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
if (fds[i].valid() && ::setns(fds[i].get(), kinds[i]) != 0) {
|
||||
throw SysError(std::string("setns ") + names[i]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+42
-1
@@ -25,10 +25,22 @@ constexpr std::uint64_t kMinSizeBytes = 64ULL << 20;
|
||||
constexpr std::uint64_t kMaxSizeBytes = 1ULL << 40;
|
||||
constexpr int kSizeWarnPercent = 80;
|
||||
constexpr int kSizeThawPercent = 95; // thaw again below this share of the cap
|
||||
constexpr int kSizeCheckIntervalMs = 10000; // floor between usage scans, never faster
|
||||
constexpr int kSizeCheckMinIntervalMs = 2000; // floor between usage scans, never faster
|
||||
constexpr int kSizeScanDutyFactor = 10; // next scan waits at least 10x the last scan's duration
|
||||
constexpr std::size_t kMaxScanEntries = 5000000; // usage scan stops (and reports) beyond this
|
||||
constexpr std::string_view kCgroupDir = "doos"; // under the delegated user@UID.service cgroup
|
||||
|
||||
// Resource limits per container (--memory, --pids, --cpus). Enforced by cgroup v2; cpus 0 means "all cores".
|
||||
constexpr std::uint64_t kDefaultMemoryBytes = 8ULL << 30;
|
||||
constexpr std::uint64_t kMinMemoryBytes = 64ULL << 20;
|
||||
constexpr std::uint64_t kMaxMemoryBytes = 1ULL << 40;
|
||||
constexpr std::uint32_t kDefaultPids = 4096;
|
||||
constexpr std::uint32_t kMinPids = 16;
|
||||
constexpr std::uint32_t kMaxPids = 4194304;
|
||||
constexpr std::uint32_t kMaxMilliCpus = 1024000; // 1024 cpus
|
||||
constexpr std::uint32_t kMinMilliCpus = 100; // 0.1 cpu
|
||||
constexpr int kCpuPeriodUs = 100000;
|
||||
|
||||
// Seeding limits
|
||||
constexpr std::size_t kMaxSkeletonDirs = 200000;
|
||||
constexpr std::size_t kMaxEtcCopyFiles = 50000;
|
||||
@@ -47,6 +59,16 @@ constexpr std::size_t kMaxCaptureBytes = 1 << 20; // listing / checksum fetches
|
||||
constexpr std::string_view kAlpineBase = "https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/";
|
||||
constexpr std::string_view kUbuntuBase = "https://cdimage.ubuntu.com/ubuntu-base/releases/";
|
||||
constexpr std::string_view kUbuntuDefaultVersion = "26.04";
|
||||
constexpr std::string_view kDebianBase = "https://raw.githubusercontent.com/debuerreotype/docker-debian-artifacts/";
|
||||
constexpr std::string_view kDebianDefaultRelease = "trixie";
|
||||
|
||||
// Release signing keys, pinned by fingerprint. Keys are fetched from the publisher and rejected unless the
|
||||
// fingerprint matches. Sources of the fingerprints: alpinelinux.org/downloads, ubuntu.com verify tutorial.
|
||||
constexpr std::string_view kAlpineKeyUrl = "https://alpinelinux.org/keys/ncopa.asc";
|
||||
constexpr std::string_view kAlpineKeyFpr = "0482D84022F52DF1C4E7CD43293ACD0907D9495A";
|
||||
constexpr std::string_view kUbuntuKeyUrl =
|
||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||
constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||
|
||||
// Networking
|
||||
constexpr std::string_view kSlirpBinary = "slirp4netns";
|
||||
@@ -65,6 +87,25 @@ constexpr std::array<std::string_view, 2> kOverlayDirs = {"/usr", "/opt"};
|
||||
// chown --reference and in-place edits work). Unreadable entries are skipped and counted.
|
||||
constexpr std::array<std::string_view, 1> kCopiedDirs = {"/etc"};
|
||||
|
||||
// Host mode /etc under --hardening default: only these entries are copied (relaxed copies everything readable).
|
||||
// Entries that may hold credentials are skipped even inside allowed directories.
|
||||
constexpr std::array<std::string_view, 53> kEtcAllow = {
|
||||
"passwd", "group", "nsswitch.conf", "hosts", "hostname", "host.conf", "localtime", "timezone", "machine-id",
|
||||
"ld.so.conf", "ld.so.conf.d", "ld.so.cache", "ssl", "ca-certificates", "ca-certificates.conf", "pki",
|
||||
"alternatives", "apt", "dpkg", "bash.bashrc", "bashrc", "profile", "profile.d", "environment", "inputrc",
|
||||
"os-release", "lsb-release", "issue", "issue.net", "debian_version", "shells", "protocols", "services",
|
||||
"mime.types", "magic", "gai.conf", "locale.gen", "locale.conf", "default", "gitconfig", "bash_completion",
|
||||
"bash_completion.d", "vim", "emacs", "xdg", "fonts", "mailcap", "wgetrc", "ssh/ssh_config",
|
||||
"ssh/ssh_config.d", "perl", "python3", "terminfo"};
|
||||
constexpr std::array<std::string_view, 8> kEtcDenyNames = {"private", "shadow", "gshadow", "sudoers", "sudoers.d",
|
||||
"auth.conf", "auth.conf.d", "ssh_host_*"};
|
||||
|
||||
// Capabilities kept in the bounding set under --hardening default (the Docker default set).
|
||||
constexpr std::array<int, 14> kKeptCaps = {0 /*CHOWN*/, 1 /*DAC_OVERRIDE*/, 3 /*FOWNER*/, 4 /*FSETID*/,
|
||||
5 /*KILL*/, 6 /*SETGID*/, 7 /*SETUID*/, 8 /*SETPCAP*/,
|
||||
10 /*NET_BIND_SERVICE*/, 13 /*NET_RAW*/, 18 /*SYS_CHROOT*/,
|
||||
27 /*MKNOD*/, 29 /*AUDIT_WRITE*/, 31 /*SETFCAP*/};
|
||||
|
||||
// Extra overlay mounted inside the private /var, only when the host has the directory (dpkg database).
|
||||
constexpr std::array<std::string_view, 1> kVarOverlayDirs = {"/var/lib/dpkg"};
|
||||
|
||||
|
||||
+61
-4
@@ -81,6 +81,29 @@ std::string formatSize(std::uint64_t b) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string base64Decode(const std::string& in) {
|
||||
std::string out;
|
||||
unsigned val = 0;
|
||||
int bits = -8;
|
||||
for (unsigned char c : in) {
|
||||
int d;
|
||||
if (c >= 'A' && c <= 'Z') d = c - 'A';
|
||||
else if (c >= 'a' && c <= 'z') d = c - 'a' + 26;
|
||||
else if (c >= '0' && c <= '9') d = c - '0' + 52;
|
||||
else if (c == '+' || c == '-') d = 62;
|
||||
else if (c == '/' || c == '_') d = 63;
|
||||
else if (c == '=' || std::isspace(c)) continue;
|
||||
else throw std::runtime_error("invalid base64 input");
|
||||
val = (val << 6) | static_cast<unsigned>(d);
|
||||
bits += 6;
|
||||
if (bits >= 0) {
|
||||
out.push_back(static_cast<char>((val >> bits) & 0xFF));
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool quiet) {
|
||||
int pipefd[2] = {-1, -1};
|
||||
if (capture && ::pipe2(pipefd, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||
@@ -145,7 +168,7 @@ void mirrorRec(const fs::path& lower, const fs::path& upper, SeedStats& st, std:
|
||||
}
|
||||
}
|
||||
|
||||
void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_t depth) {
|
||||
void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_t depth, const SkipFn& skip) {
|
||||
if (depth > spec::kMaxTreeDepth) throw std::runtime_error("tree too deep: " + from.string());
|
||||
std::error_code ec;
|
||||
fs::directory_iterator it(from, ec), end;
|
||||
@@ -156,13 +179,14 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
|
||||
const fs::path& src = it->path();
|
||||
fs::path dst = to / src.filename();
|
||||
if (::lstat(src.c_str(), &sb) != 0) { ++st.skipped; continue; }
|
||||
if (skip && skip(src)) { ++st.skipped; continue; }
|
||||
if (st.made >= spec::kMaxEtcCopyFiles) throw std::runtime_error("copy file cap reached: " + from.string());
|
||||
if (S_ISDIR(sb.st_mode)) {
|
||||
if (::mkdir(dst.c_str(), (sb.st_mode & 07777) | 0700) != 0 && errno != EEXIST) {
|
||||
throw SysError("mkdir " + dst.string());
|
||||
}
|
||||
++st.made;
|
||||
copyRec(src, dst, st, depth + 1);
|
||||
copyRec(src, dst, st, depth + 1, skip);
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
std::error_code e2;
|
||||
fs::remove(dst, e2);
|
||||
@@ -217,13 +241,46 @@ SeedStats mirrorDirs(const fs::path& lower, const fs::path& upper) {
|
||||
return st;
|
||||
}
|
||||
|
||||
SeedStats copyTree(const fs::path& from, const fs::path& to) {
|
||||
SeedStats copyTree(const fs::path& from, const fs::path& to, const SkipFn& skip) {
|
||||
SeedStats st;
|
||||
mkdirs(to);
|
||||
copyRec(from, to, st, 0);
|
||||
copyRec(from, to, st, 0, skip);
|
||||
return st;
|
||||
}
|
||||
|
||||
SeedStats copyEntries(const fs::path& from, const fs::path& to, const std::vector<std::string>& entries,
|
||||
const SkipFn& skip) {
|
||||
SeedStats total;
|
||||
mkdirs(to);
|
||||
for (const auto& e : entries) {
|
||||
fs::path src = from / e, dst = to / e;
|
||||
struct stat sb;
|
||||
if (::lstat(src.c_str(), &sb) != 0) continue; // not present on this host: nothing to copy, nothing skipped
|
||||
if (skip && skip(src)) { ++total.skipped; continue; }
|
||||
mkdirs(dst.parent_path());
|
||||
if (S_ISDIR(sb.st_mode)) {
|
||||
SeedStats s = copyTree(src, dst, skip);
|
||||
total.made += s.made; total.skipped += s.skipped; total.bytes += s.bytes;
|
||||
} else if (S_ISLNK(sb.st_mode)) {
|
||||
std::error_code ec;
|
||||
fs::remove(dst, ec);
|
||||
fs::create_symlink(fs::read_symlink(src), dst);
|
||||
++total.made;
|
||||
} else if (S_ISREG(sb.st_mode) && ::access(src.c_str(), R_OK) == 0) {
|
||||
std::error_code ec;
|
||||
fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
|
||||
if (ec) { ++total.skipped; continue; }
|
||||
::chmod(dst.c_str(), (sb.st_mode & 07777) | 0600);
|
||||
total.bytes += static_cast<std::uint64_t>(sb.st_size);
|
||||
++total.made;
|
||||
} else {
|
||||
++total.skipped;
|
||||
}
|
||||
if (total.bytes > spec::kMaxEtcCopyBytes) throw std::runtime_error("copy byte cap reached: " + from.string());
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
SeedStats copyFiles(const fs::path& from, const fs::path& to) {
|
||||
SeedStats st;
|
||||
std::error_code ec;
|
||||
|
||||
+9
-1
@@ -2,6 +2,7 @@
|
||||
#include <cerrno>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -51,6 +52,8 @@ private:
|
||||
std::uint64_t parseSize(const std::string& text);
|
||||
std::string formatSize(std::uint64_t bytes);
|
||||
|
||||
std::string base64Decode(const std::string& in); // lenient on whitespace, throws on bad characters
|
||||
|
||||
struct ProcResult {
|
||||
int code = -1;
|
||||
std::string out; // stdout, only when captured
|
||||
@@ -67,7 +70,12 @@ struct SeedStats {
|
||||
// Creates every directory of `lower` in `upper` (owner rwx added) so a unmapped-owner lower stays writable.
|
||||
SeedStats mirrorDirs(const std::filesystem::path& lower, const std::filesystem::path& upper);
|
||||
// Copies dirs, regular files and symlinks readable by the caller. Capped by spec.hpp limits.
|
||||
SeedStats copyTree(const std::filesystem::path& from, const std::filesystem::path& to);
|
||||
// `skip` (optional) is asked for every entry and returns true to leave it out (counted as skipped).
|
||||
using SkipFn = std::function<bool(const std::filesystem::path&)>;
|
||||
SeedStats copyTree(const std::filesystem::path& from, const std::filesystem::path& to, const SkipFn& skip = nullptr);
|
||||
// Copies only the named entries (relative paths, files, links or dirs) of `from` into `to`.
|
||||
SeedStats copyEntries(const std::filesystem::path& from, const std::filesystem::path& to,
|
||||
const std::vector<std::string>& entries, const SkipFn& skip);
|
||||
// Copies only the regular files directly inside `from` (readable ones) into `to`; existing targets stay.
|
||||
SeedStats copyFiles(const std::filesystem::path& from, const std::filesystem::path& to);
|
||||
// Sum of allocated bytes (st_blocks) below p, lstat only. `skipped` counts unreadable dirs.
|
||||
|
||||
+96
-10
@@ -102,22 +102,108 @@ check "image in use is a dry run too" bash -c "! '$DOOS' image delete alpine"
|
||||
"$DOOS" attach --image ubuntu u1 -- sh -c 'apt-get update >/dev/null 2>&1; apt-get install -y --no-install-recommends cowsay >/dev/null 2>&1; dpkg --audit && /usr/games/cowsay ok >/dev/null' && ok "ubuntu image: apt install completes" || bad "ubuntu image: apt install completes"
|
||||
"$DOOS" delete --yes u1 >/dev/null; "$DOOS" image delete --yes ubuntu >/dev/null
|
||||
|
||||
# ---- size cap (needs cgroup v2 delegation; takes about 40 s because the scan interval has a 10 s floor)
|
||||
echo "--- size cap"
|
||||
"$DOOS" get_or_create --image alpine --size 64m cap >/dev/null 2>&1
|
||||
"$DOOS" attach cap -- sh -c 'dd if=/dev/zero of=/root/big bs=1M count=100 2>/dev/null; sleep 40' >/tmp/doos-cap.$$ 2>&1 &
|
||||
sleep 15
|
||||
# ---- size cap (needs cgroup v2 delegation; scans every 2 s at the earliest)
|
||||
echo "--- size cap, accounting of other ids"
|
||||
"$DOOS" get_or_create --size 64m cap >/dev/null 2>&1
|
||||
# 80 MB written as container uid 5 into a mode-700 directory the host user cannot read: only an id-map scan sees it
|
||||
"$DOOS" attach cap -- python3 -c '
|
||||
import os,time
|
||||
os.mkdir("/root/d5"); os.chown("/root/d5",5,5); os.chmod("/root/d5",0o700); os.setgid(5); os.setuid(5)
|
||||
for n in (1,2):
|
||||
with open("/root/d5/f%d"%n,"wb") as f: f.write(b"x"*(40*1024*1024)); f.flush(); os.fsync(f.fileno())
|
||||
time.sleep(40)' >/tmp/doos-cap.$$ 2>&1 &
|
||||
sleep 9
|
||||
if grep -q "FROZEN" /tmp/doos-cap.$$; then
|
||||
ok "size cap freezes"
|
||||
"$DOOS" attach cap -- rm /root/big >/dev/null 2>&1; sleep 12
|
||||
ok "size cap freezes (counts other uids)"
|
||||
"$DOOS" attach cap -- sh -c 'grep -q /shell /proc/self/cgroup 2>/dev/null; rm -rf /root/d5' >/dev/null 2>&1; sleep 5
|
||||
grep -q thawed /tmp/doos-cap.$$ && ok "size cap thaws after cleanup" || bad "size cap thaws after cleanup"
|
||||
elif grep -q "cannot freeze" /tmp/doos-cap.$$; then
|
||||
elif grep -q "cannot freeze\|not enforced" /tmp/doos-cap.$$; then
|
||||
echo "skip size cap: no cgroup delegation here (warn-only mode confirmed)"
|
||||
else
|
||||
bad "size cap freezes"
|
||||
bad "size cap freezes (counts other uids)"
|
||||
fi
|
||||
kill %% 2>/dev/null; wait 2>/dev/null; rm -f /tmp/doos-cap.$$
|
||||
"$DOOS" delete --yes cap >/dev/null 2>&1; "$DOOS" image delete --yes alpine >/dev/null 2>&1
|
||||
"$DOOS" delete --yes cap >/dev/null 2>&1
|
||||
|
||||
# ---- resource limits, rescue leaf, hardening
|
||||
echo "--- limits and hardening"
|
||||
"$DOOS" get_or_create --memory 256m --pids 32 --cpus 0.5 lim >/dev/null 2>&1
|
||||
"$DOOS" list | grep -q "^lim" && ok "limit options accepted" || bad "limit options accepted"
|
||||
r=$("$DOOS" attach lim -- python3 -c 'b=bytearray(b"x"*(400*1024*1024)); print("survived")' 2>&1)
|
||||
echo "$r" | grep -q "killed by its memory limit" && ! echo "$r" | grep -q survived && ok "memory limit kills the container" || bad "memory limit kills the container"
|
||||
r=$("$DOOS" attach lim -- sh -c 'for i in $(seq 1 80); do sleep 3 & done 2>&1; wait' 2>&1)
|
||||
echo "$r" | grep -q "process limit" && ok "pids limit enforced and reported" || bad "pids limit enforced and reported"
|
||||
check "limits can change later" "$DOOS" get_or_create --memory 512m lim
|
||||
check "memory below minimum refused" bash -c "! '$DOOS' get_or_create --memory 1m z3"
|
||||
[ ! -e "$DOOS_HOME/containers/z3" ] && ok "failed create leaves nothing" || bad "failed create leaves nothing"
|
||||
check "bad cpus refused" bash -c "! '$DOOS' get_or_create --cpus abc z4"
|
||||
"$DOOS" attach lim -- sleep 6 >/dev/null 2>&1 & sleep 1.5
|
||||
[ "$("$DOOS" attach lim -- sh -c 'grep -c /shell /proc/self/cgroup')" = "1" ] && ok "second attach runs in the shell cgroup" || bad "second attach runs in the shell cgroup"
|
||||
wait
|
||||
h() { "$DOOS" attach lim -- python3 -c "$1" 2>&1; }
|
||||
[ "$(h 'import ctypes,errno;l=ctypes.CDLL(None,use_errno=True);print(l.syscall(250,0,0,0,0,0),errno.errorcode[ctypes.get_errno()])')" = "-1 EPERM" ] && ok "seccomp: keyctl denied" || bad "seccomp: keyctl denied"
|
||||
[ "$(h 'import ctypes,errno;l=ctypes.CDLL(None,use_errno=True);print(l.syscall(272,0x10000000),errno.errorcode[ctypes.get_errno()])')" = "-1 EPERM" ] && ok "seccomp: nested user namespace denied" || bad "seccomp: nested user namespace denied"
|
||||
[ "$(h 'import ctypes,errno;l=ctypes.CDLL(None,use_errno=True);print(l.syscall(435,0,0),errno.errorcode[ctypes.get_errno()])')" = "-1 ENOSYS" ] && ok "seccomp: clone3 answers ENOSYS" || bad "seccomp: clone3 answers ENOSYS"
|
||||
"$DOOS" attach lim -- sh -c 'mount -t tmpfs t /mnt' >/dev/null 2>&1 && bad "mount denied" || ok "mount denied"
|
||||
[ "$("$DOOS" attach lim -- sh -c 'grep ^CapEff /proc/self/status | cut -f2')" = "00000000a80425fb" ] && ok "capabilities reduced to the default set" || bad "capabilities reduced to the default set"
|
||||
"$DOOS" attach lim -- sh -c 'gcc --version >/dev/null && curl -sS -o /dev/null https://example.com && git --version >/dev/null && python3 -c "import ssl" && ping -c1 -W3 1.1.1.1 >/dev/null' && ok "common tools work under hardening" || bad "common tools work under hardening"
|
||||
[ "$("$DOOS" attach lim -- sh -c 'ls /etc | wc -l')" -lt 80 ] && ok "etc is an allowlist" || bad "etc is an allowlist"
|
||||
"$DOOS" attach lim -- sh -c 'ls /etc/shadow /etc/sudoers /etc/ssh/ssh_host_rsa_key' >/dev/null 2>&1 && bad "etc: no credentials" || ok "etc: no credentials"
|
||||
"$DOOS" get_or_create --hardening relaxed rx >/dev/null 2>&1
|
||||
"$DOOS" attach rx -- sh -c 'unshare -U true && grep -q "^Seccomp:.0" /proc/self/status' >/dev/null 2>&1 && ok "relaxed: no seccomp, nested userns allowed" || bad "relaxed: no seccomp, nested userns allowed"
|
||||
check "hardening fixed at creation" bash -c "! '$DOOS' get_or_create --hardening relaxed lim"
|
||||
"$DOOS" delete --yes lim >/dev/null; "$DOOS" delete --yes rx >/dev/null
|
||||
|
||||
# ---- pasta network mode
|
||||
echo "--- pasta"
|
||||
python3 -c '
|
||||
import socket,time
|
||||
s=socket.socket(); s.bind(("127.0.0.1",48765)); s.listen(5)
|
||||
a=socket.socket(socket.AF_UNIX); a.bind("\0doos-abstract-test"); a.listen(5)
|
||||
time.sleep(25)' & lp=$!
|
||||
sleep 1
|
||||
"$DOOS" get_or_create --net pasta pa >/dev/null 2>&1
|
||||
pr() { "$DOOS" attach pa -- python3 -c "$1" 2>&1; }
|
||||
[ "$(pr 'import socket;s=socket.create_connection(("127.0.0.1",48765),2);print("ok")')" = "ok" ] && ok "pasta: host loopback reachable" || bad "pasta: host loopback reachable"
|
||||
[ "$(pr 'import socket;s=socket.socket(socket.AF_UNIX)
|
||||
try: s.connect("\0doos-abstract-test");print("reachable")
|
||||
except Exception: print("blocked")')" = "blocked" ] && ok "pasta: host abstract sockets not shared" || bad "pasta: host abstract sockets not shared"
|
||||
[ "$(pr 'import urllib.request;print(urllib.request.urlopen("https://example.com",timeout=6).status)')" = "200" ] && ok "pasta: dns and https" || bad "pasta: dns and https"
|
||||
"$DOOS" attach pa -- python3 -c '
|
||||
import socket
|
||||
s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(("0.0.0.0",48911)); s.listen(1); s.settimeout(8)
|
||||
c,_=s.accept(); print(c.recv(5).decode())' >/tmp/doos-in.$$ 2>&1 &
|
||||
sleep 2.5; python3 -c 'import socket;s=socket.create_connection(("127.0.0.1",48911),2);s.sendall(b"hello")' 2>/dev/null; wait %2 2>/dev/null; sleep 0.5
|
||||
grep -q hello /tmp/doos-in.$$ && ok "pasta: inbound port forwarded" || bad "pasta: inbound port forwarded"
|
||||
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"
|
||||
"$DOOS" image delete --yes alpine >/dev/null
|
||||
shim=$(mktemp -d)
|
||||
printf '#!/bin/sh\ncase "$*" in\n *--with-colons*) echo "fpr:::::::::0482D84022F52DF1C4E7CD43293ACD0907D9495A:";;\n *--verify*) echo "[GNUPG:] GOODSIG AAAAAAAAAAAAAAAA M"; echo "[GNUPG:] VALIDSIG 1111111111111111111111111111111111111111 2026-01-01 1 0 4 0 1 10 00 1111111111111111111111111111111111111111";;\nesac\nexit 0\n' > $shim/gpg; chmod +x $shim/gpg
|
||||
PATH=$shim:$PATH "$DOOS" image add alpine >/dev/null 2>&1 && bad "signature by the wrong key refused" || ok "signature by the wrong key refused"
|
||||
[ ! -e "$DOOS_HOME/images/alpine" ] && ok "refused signature leaves no image" || bad "refused signature leaves no image"
|
||||
sed -i 's/GOODSIG AAAAAAAAAAAAAAAA M/BADSIG 293ACD0907D9495A N/' $shim/gpg
|
||||
PATH=$shim:$PATH "$DOOS" image add alpine >/dev/null 2>&1 && bad "BADSIG refused" || ok "BADSIG refused"
|
||||
rm -rf $shim
|
||||
"$DOOS" attach --image debian d1 -- sh -c '. /etc/os-release; echo $ID; apt-get update >/dev/null 2>&1; apt-get install -y --no-install-recommends cowsay >/dev/null 2>&1; dpkg --audit && /usr/games/cowsay ok >/dev/null' 2>/dev/null | grep -q debian && ok "debian image: apt install completes" || bad "debian image: apt install completes"
|
||||
"$DOOS" delete --yes d1 >/dev/null; "$DOOS" image delete --yes debian >/dev/null
|
||||
|
||||
echo "passed=$pass failed=$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
Reference in New Issue
Block a user