Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1541b77e6 | ||
|
|
7adf43dd03 | ||
|
|
4c754b20da | ||
|
|
ded4e7f1a1 | ||
|
|
598a585eb8 | ||
|
|
4c4bb1a83a |
@@ -0,0 +1,69 @@
|
|||||||
|
# Gitea Actions. See docs/CI.md for what each job needs, the one-time runner setup, and the
|
||||||
|
# known gaps between Gitea Actions and GitHub Actions this file was written around (notably:
|
||||||
|
# timeout-minutes and concurrency are parsed but not enforced by Gitea as of the 1.25 docs, so
|
||||||
|
# the real cap on every step is the `timeout <seconds>` wrapped around it, not the YAML key).
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Needs only a C++20 toolchain and CMake: runs on any Docker-mode act_runner.
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10 # label only on Gitea (see docs/CI.md); the real cap is the `timeout` below
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: install build dependencies
|
||||||
|
run: timeout 180 sh -c 'apt-get update && apt-get install -y --no-install-recommends build-essential cmake'
|
||||||
|
- name: build (-Werror)
|
||||||
|
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
|
||||||
|
- name: docs cross-link check
|
||||||
|
run: timeout 60 scripts/check-docs.sh
|
||||||
|
|
||||||
|
# No special runner privilege needed: scripts/valgrind.sh only fails on what valgrind itself
|
||||||
|
# reports, never on doos's own exit code, so it is still a real check even when this container's
|
||||||
|
# own policy blocks the user namespace doos tries to create (see docs/CI.md).
|
||||||
|
valgrind:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: install build and valgrind dependencies
|
||||||
|
run: timeout 180 sh -c 'apt-get update && apt-get install -y --no-install-recommends build-essential cmake valgrind'
|
||||||
|
- name: build
|
||||||
|
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
|
||||||
|
- name: memcheck against the fixed invocation set
|
||||||
|
run: timeout 300 scripts/valgrind.sh "$(pwd)/build/doos"
|
||||||
|
- name: keep the per-step logs on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: valgrind-reports
|
||||||
|
path: build/valgrind-reports/
|
||||||
|
|
||||||
|
# Real overlayfs, real user/mount/pid namespaces, real cgroup v2 limits, real downloads: needs
|
||||||
|
# the self-hosted host-mode runner set up per docs/CI.md (label "doos-host"), not a container.
|
||||||
|
test:
|
||||||
|
runs-on: [self-hosted, doos-host]
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: build
|
||||||
|
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
|
||||||
|
- name: end-to-end suite
|
||||||
|
run: timeout 600 tests/run.sh "$(pwd)/build/doos"
|
||||||
|
|
||||||
|
# Named CVE regression set against the binary this run just built. Same runner requirement as
|
||||||
|
# `test`, for the same reason (several checks attach a real container).
|
||||||
|
audit:
|
||||||
|
runs-on: [self-hosted, doos-host]
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: build
|
||||||
|
run: timeout 300 cmake -S . -B build && timeout 300 cmake --build build
|
||||||
|
- name: CVE regression checks
|
||||||
|
run: timeout 300 make -C audit -j8 test DOOS="$(pwd)/build/doos"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||||
|
|
||||||
Rules for any AI agent (or human) changing this repository. The user's global rules
|
Rules for any AI agent (or human) changing this repository. The user's global rules
|
||||||
also apply; this file adds the project-specific ones. Keep this file current: it is
|
also apply; this file adds the project-specific ones. Keep this file current: it is
|
||||||
@@ -49,11 +49,14 @@ Never hand-edit `VERSION` or create tags manually. Never skip the tests to relea
|
|||||||
| A measurement, or anything the charts show | `make bench` then `make charts` (rewrites `docs/charts/*.svg` and every `<!--bench:...-->` number and table in `COMPARISON.md` and `docs/RESEARCH.md`); never type those numbers by hand |
|
| A measurement, or anything the charts show | `make bench` then `make charts` (rewrites `docs/charts/*.svg` and every `<!--bench:...-->` number and table in `COMPARISON.md` and `docs/RESEARCH.md`); never type those numbers by hand |
|
||||||
| A feature or limit that appears in the comparison tables | `COMPARISON.md` (tables, the gaps list, the conclusion) |
|
| A feature or limit that appears in the comparison tables | `COMPARISON.md` (tables, the gaps list, the conclusion) |
|
||||||
| A code path one of the `audit/CVE-*` checks targets (overlay writes, tar extraction, mount/directory creation, net namespace sharing, seccomp denylist) | re-run `make audit`; update that CVE's `README.md` if the verdict's reasoning changed |
|
| A code path one of the `audit/CVE-*` checks targets (overlay writes, tar extraction, mount/directory creation, net namespace sharing, seccomp denylist) | re-run `make audit`; update that CVE's `README.md` if the verdict's reasoning changed |
|
||||||
|
| Allocation shape of `containerInit`, `setupRoot`, `doMount` or `Sandbox::start` | re-run `make valgrind`; if a suppressed site's stack trace changed, regenerate it (see [valgrind/README.md](valgrind/README.md)) and update `valgrind/doos.supp`'s comment |
|
||||||
| A new or renamed markdown file | `scripts/check-docs.sh` DOCS list, the nav line (`**Docs:**`) of every document, `README.md` Project docs |
|
| A new or renamed markdown file | `scripts/check-docs.sh` DOCS list, the nav line (`**Docs:**`) of every document, `README.md` Project docs |
|
||||||
| Image catalog, sources, verification, pinned keys | `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` |
|
| 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` |
|
| Limits, cgroup layout | `SPEC.md` Resource limits, `docs/ARCHITECTURE.md` |
|
||||||
| Anything above, in every release | `README.md` version line and `CHANGELOG.md` entry |
|
| Anything above, in every release | `CHANGELOG.md` entry (`README.md` points at `VERSION`, nothing to edit there) |
|
||||||
|
| The CI workflow or what a job needs | `.gitea/workflows/ci.yml`, `docs/CI.md` |
|
||||||
|
| The davmount flow, pinned rclone version/key, or what `--mount`/`--davmount` accepts | `SPEC.md` (CLI options, davmount section, limits), `docs/ARCHITECTURE.md` davmount flow, `docs/RESEARCH.md` G13 |
|
||||||
|
|
||||||
Docs describe what the system does today. No roadmap text. A gap is listed next to
|
Docs describe what the system does today. No roadmap text. A gap is listed next to
|
||||||
what would close it. A number in the docs needs a run behind it, else say it is unmeasured.
|
what would close it. A number in the docs needs a run behind it, else say it is unmeasured.
|
||||||
@@ -79,20 +82,32 @@ the script.
|
|||||||
|
|
||||||
`tests/run.sh [path/to/doos]` runs the real path: real overlayfs, namespaces,
|
`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
|
slirp4netns, filesystem, real downloads of the Alpine and Ubuntu images, real apt/apk installs, the size
|
||||||
freeze (about 4 minutes in total, needs network, `pasta`, `gpg` and cgroup v2 delegation). Add a case for every behavior change. A green run is required
|
freeze, a real WebDAV round trip through a disposable local `rclone serve webdav` fixture (about 4-5
|
||||||
before release. On Ubuntu 24.04+ the AppArmor profile in `apparmor/doos` must be
|
minutes in total, needs network, `pasta`, `gpg`, `unzip` and cgroup v2 delegation). Add a case for every
|
||||||
installed once for the binary being tested (`doos apparmor`; needs sudo, ask the user, do not do it yourself).
|
behavior change. A green run is required before release. On Ubuntu 24.04+ the AppArmor profile in
|
||||||
|
`apparmor/doos` must be installed once for the binary being tested (`doos apparmor`; needs sudo, ask the
|
||||||
|
user, do not do it yourself).
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yml` runs `build`, `valgrind`, `test` and `audit` on every push (Gitea Actions).
|
||||||
|
`test` and `audit` need the self-hosted host-mode runner described in [docs/CI.md](docs/CI.md) (real
|
||||||
|
namespaces, cgroup v2 delegation, the AppArmor profile); the workflow itself never runs `sudo`, same
|
||||||
|
rule as everywhere else. See that file before changing the workflow or what a job needs.
|
||||||
|
|
||||||
## Make targets
|
## Make targets
|
||||||
|
|
||||||
`make help` lists them: `build`, `test`, `audit` (named CVE regression checks against `build/doos`,
|
`make help` lists them: `build`, `test`, `audit` (named CVE regression checks against `build/doos`,
|
||||||
see [audit/README.md](audit/README.md)), `check-docs`, `bench` (re-measure into `docs/charts/bench.json`),
|
see [audit/README.md](audit/README.md)), `valgrind` (memcheck against a fixed set of invocations,
|
||||||
|
see [valgrind/README.md](valgrind/README.md)), `check-docs`, `bench` (re-measure into `docs/charts/bench.json`),
|
||||||
`charts` (SVGs and generated numbers from that file), `docs`, `release KIND=patch MSG="..."`, `clean`.
|
`charts` (SVGs and generated numbers from that file), `docs`, `release KIND=patch MSG="..."`, `clean`.
|
||||||
The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/`, `audit/` and `docs/charts/`.
|
The Makefile is a thin wrapper; the logic lives in `CMakeLists.txt`, `scripts/`, `audit/`, `valgrind/` and `docs/charts/`.
|
||||||
|
|
||||||
## Environment notes
|
## Environment notes
|
||||||
|
|
||||||
- State dir: `$DOOS_HOME`, else `$XDG_DATA_HOME/doos`, else `~/.local/share/doos`.
|
- State dir: `$DOOS_HOME`, else `$XDG_DATA_HOME/doos`, else `~/.local/share/doos`.
|
||||||
Tests use a temp `DOOS_HOME`; never point tests at the user's real store.
|
Tests use a temp `DOOS_HOME`; never point tests at the user's real store.
|
||||||
- Build: `cmake -S . -B build && cmake --build build`.
|
- Build: `cmake -S . -B build && cmake --build build`.
|
||||||
- Requirements: Linux >= 5.11, `slirp4netns` for the default network mode.
|
- Requirements: Linux >= 5.11, `slirp4netns` for the default network mode. `unzip` and `fusermount3`
|
||||||
|
(or `fusermount`) are needed only for `--mount`/`--davmount` against a `dav://`/`davs://`/`http(s)://`
|
||||||
|
source; doos installs its own `rclone` under `$DOOS_HOME/tools/`, never the system's.
|
||||||
|
|||||||
+37
-1
@@ -1,6 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||||
|
|
||||||
|
## 1.6.0 - 2026-10-25
|
||||||
|
- `--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source: auto-mounts WebDAV. doos downloads, GPG-verifies against a pinned key and installs `rclone` once under `$DOOS_HOME/tools/` (never the system, never `PATH`, same trust model as the catalog images), then drives it on the host with an on-the-fly connection string (no config file, password passed through `rclone obscure`, never on the mount's command line) before the container's namespaces exist. The mount is deduplicated by target (url + credentials + vendor), reused across containers and attaches, and runs detached so it outlives the `attach` that created it. `--dav-user`, `--dav-pass`, `--dav-vendor` and `--dav-insecure` configure a target without putting credentials in the URL. Tracked as gap G13 in [RESEARCH](docs/RESEARCH.md); measured there against a local `rclone serve webdav` fixture (install, reuse at ~40 ms, write-back durability, and each failure mode: wrong credentials, unreachable server, non-WebDAV endpoint).
|
||||||
|
- `doos davmount list` / `doos davmount umount [--yes] ID`: inspect or tear down a managed WebDAV mount; dry-run by default like `delete`/`image delete`.
|
||||||
|
- Fix (found while building the above): the classic `--mount SRC:DST[:ro]` could not take credentials with `:`/`;` in them because mounts were serialized as one delimited line; container `meta` now stores mounts as indexed keys (one field per line), with the old single-line format still read for containers created under 1.5.0.
|
||||||
|
- Refactor: `curl`/gpg download-and-verify helpers moved from `image.cpp` into `util.{hpp,cpp}` (`fetchText`, `fetchFile`, `TempDir`, `isHex64`, `verifyDetachedSignature`, `verifyClearsignedSignature`) so `davmount.cpp` reuses the exact same, already-tested verification code instead of a second copy; no behavior change (full image signature test suite re-passes).
|
||||||
|
- New dependency, only for davmount: `unzip` (to unpack rclone's release archive) and `fusermount3`/`fusermount` (to unmount it); documented in README/AGENTS.
|
||||||
|
- Tests: 10 new end-to-end checks against a real local WebDAV server (read, write durability, idempotent reuse, `--davmount` with `--dav-user`/`--dav-pass`, read-only, wrong-password classification, unknown vendor rejected, `davmount list`/`umount`).
|
||||||
|
- Docs: [COMPARISON](COMPARISON.md) section 4's "Where Docker is better" no longer lists bind mounts as a doos gap (stale since 1.5.0); charts and generated numbers re-measured.
|
||||||
|
|
||||||
|
## 1.5.0 - 2026-10-24
|
||||||
|
- `--mount SRC:DST[:ro]` (repeatable, max 8): bind-mounts a host directory into the container, outside the `--size` cap. Lets a container reach storage that is not local, for example a WebDAV share mounted on the host first with `rclone mount` or `davfs2`; doos never speaks a network filesystem protocol itself, it only bind-mounts what the host already mounted. Changeable like `--memory`/`--pids`/`--cpus`: replaces the whole mount list, applies at the next start. Tracked as gap G12 in [RESEARCH](docs/RESEARCH.md); 5 new end-to-end checks (read, write, read-only, missing source rejected, too many rejected).
|
||||||
|
- Docs: [COMPARISON](COMPARISON.md) section 1 and the gaps list updated (doos now has host-dir bind mounts); charts and generated numbers re-measured (`make bench && make charts`).
|
||||||
|
|
||||||
|
## 1.4.0 - 2026-10-24
|
||||||
|
- CI: `.gitea/workflows/ci.yml` (Gitea Actions) runs `build`, `valgrind`, `test` and `audit` on every push. `build`/`valgrind` run on any Docker-mode runner; `test`/`audit` need a self-hosted host-mode runner (real namespaces, cgroup v2 delegation, the AppArmor profile), documented with its one-time setup in the new [docs/CI.md](docs/CI.md). Every step wraps its own `timeout`, since Gitea Actions parses but does not enforce `timeout-minutes`/`concurrency` (checked against the 1.25 docs).
|
||||||
|
- Fixed, from [review2.md](review2.md): `parseSize` (`src/util.cpp`) overflowed silently on an oversized input (`--size 16777217t` was accepted as exactly `1.0T` instead of rejected); now checked before the shift and rejected. Tracked as gap G9 in [RESEARCH](docs/RESEARCH.md); regression case added to `tests/run.sh`.
|
||||||
|
- Fixed, from [review2.md](review2.md): `newuidmap`, `newgidmap`, `slirp4netns` and `pasta` were each pinned to one hardcoded `/usr/bin/...` path; they now go through `resolveBinary()` (state-dir override, then a fixed list of system directories), the same resolution `curl`/`tar`/`sha256sum`/`gpg` already used. Tracked as gap G10.
|
||||||
|
- Docs: the `setns`-before-hardening ordering invariant in `Sandbox::join` and `Sandbox::start` (seccomp denies `setns`, and a filter is inherited for life) is now a comment at both call sites, not only in reviewer heads. The hand-rolled Alpine/Debian catalog parsers are logged as an accepted trade-off, gap G11, rather than left undocumented.
|
||||||
|
- Added `LICENSE` (MIT).
|
||||||
|
- Docs: README's two overlapping, partly stale intro blocks (a leftover `currently 1.1.3` version line, a narrower duplicate usage example) collapsed into one accurate one.
|
||||||
|
|
||||||
|
## 1.3.1 - 2026-10-24
|
||||||
|
- Docs: gap G8 (`fsutil::mkdirs` following a planted symlink, [RESEARCH](docs/RESEARCH.md)) marked fixed, re-measured with `make -C audit/CVE-2021-30465 test` (PASS); removed from [COMPARISON](COMPARISON.md)'s gaps list. The fix itself shipped in 1.3.0 as part of the security-review hardening; this corrects the docs to match.
|
||||||
|
|
||||||
|
## 1.3.0 - 2026-10-24
|
||||||
|
- Security review applied ([review.md](review.md)): `slirp4netns`, `pasta`, `newuidmap` and `newgidmap` are never searched on `PATH`; they exec via absolute paths with availability checked up front, and the helpers run with a scrubbed `PATH`.
|
||||||
|
- Compat-directory mounts pin the parent through an `O_PATH` fd so a symlink swap between check and use cannot happen (TOCTOU); symlinks whose target cannot be pinned are not recreated, fail closed instead.
|
||||||
|
- `--i-understand` is now required for `--hardening relaxed` and `--net host`; reduced sandboxing is also reported loudly at attach time.
|
||||||
|
- Tests: the e2e suite covers all of the above (`passed=88 failed=0`). Docs: [RESEARCH](docs/RESEARCH.md), [SPEC](SPEC.md), [README](README.md) updated in the same change.
|
||||||
|
|
||||||
|
## 1.2.1 - 2026-10-23
|
||||||
|
- `scripts/release.sh`: commit trailer model name corrected (no functional change).
|
||||||
|
|
||||||
|
## 1.2.0 - 2026-10-23
|
||||||
|
- `audit/`: 10 more named, sourced CVEs (18 total): the overlayfs-in-unprivileged-userns family (`CVE-2021-3493`, `CVE-2023-2640`, `CVE-2023-32629`, `CVE-2015-1328`, directly doos's own mount on every attach), `CVE-2022-0492` (cgroup v1 release_agent; doos mounts no `/sys`), `CVE-2022-27650` (inheritable capabilities at container start), `CVE-2019-16884` and `CVE-2023-28642` (runc's `/proc` mount-target handling, tested against doos's own `/proc` mount), `CVE-2018-12020` (GnuPG SigSpoof, static check of the exact `gpg` invocation in `src/image.cpp`), `CVE-2020-7039` (libslirp memory-safety cluster, version check against `slirp4netns --version`).
|
||||||
|
|
||||||
## 1.1.5 - 2026-10-23
|
## 1.1.5 - 2026-10-23
|
||||||
- `audit/`: 8 named, sourced CVEs relevant to doos's own mechanisms (overlay writes, tar extraction, mount/directory creation, net namespace sharing, the mount-family seccomp denylist, kernel user-namespace exposure), each with its own `README.md`, `Makefile` and non-destructive `reproduce.sh` against `build/doos`. `make audit` runs them concurrently (`-j8`, recursive make); `make -C audit list|report` also available. Kernel memory-corruption CVEs (Dirty Pipe, Dirty COW, `legacy_parse_param`) are checked by kernel version only, no exploit code.
|
- `audit/`: 8 named, sourced CVEs relevant to doos's own mechanisms (overlay writes, tar extraction, mount/directory creation, net namespace sharing, the mount-family seccomp denylist, kernel user-namespace exposure), each with its own `README.md`, `Makefile` and non-destructive `reproduce.sh` against `build/doos`. `make audit` runs them concurrently (`-j8`, recursive make); `make -C audit list|report` also available. Kernel memory-corruption CVEs (Dirty Pipe, Dirty COW, `legacy_parse_param`) are checked by kernel version only, no exploit code.
|
||||||
|
|||||||
+3
-2
@@ -9,7 +9,8 @@ if(NOT CMAKE_BUILD_TYPE)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
add_executable(doos
|
add_executable(doos
|
||||||
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)
|
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 src/davmount.cpp)
|
||||||
target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(doos PRIVATE -Wall -Wextra -Wpedantic -Werror -D_FORTIFY_SOURCE=3 -fstack-protector-strong -O2)
|
||||||
|
target_link_options(doos PRIVATE -Wl,-z,relro -Wl,-z,now)
|
||||||
target_compile_definitions(doos PRIVATE DOOS_VERSION="${DOOS_VERSION}")
|
target_compile_definitions(doos PRIVATE DOOS_VERSION="${DOOS_VERSION}")
|
||||||
install(TARGETS doos RUNTIME DESTINATION bin)
|
install(TARGETS doos RUNTIME DESTINATION bin)
|
||||||
|
|||||||
+21
-22
@@ -1,6 +1,6 @@
|
|||||||
# doos compared with Docker, Podman and others
|
# doos compared with Docker, Podman and others
|
||||||
|
|
||||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||||
|
|
||||||
This page puts doos next to the tools people usually reach for instead. It separates three kinds of claims, and every row says which one it is:
|
This page puts doos next to the tools people usually reach for instead. It separates three kinds of claims, and every row says which one it is:
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
|
|||||||
| Who can use it safely | any user | only trusted users: access to the daemon is root-equivalent [S: [Docker security](https://docs.docker.com/engine/security/), [iximiuz](https://labs.iximiuz.com/tutorials/docker-security-introduction-a859718d)] | any user | any user |
|
| Who can use it safely | any user | only trusted users: access to the daemon is root-equivalent [S: [Docker security](https://docs.docker.com/engine/security/), [iximiuz](https://labs.iximiuz.com/tutorials/docker-security-introduction-a859718d)] | any user | any user |
|
||||||
| Image source | tarball, https URL with sha256, small catalog (Alpine, Ubuntu, Debian) [D] | OCI registries, Dockerfile builds [K] | same as Docker | OCI registries, Containerfile builds [K] |
|
| Image source | tarball, https URL with sha256, small catalog (Alpine, Ubuntu, Debian) [D] | OCI registries, Dockerfile builds [K] | same as Docker | OCI registries, Containerfile builds [K] |
|
||||||
| Build images | no [D] | yes (BuildKit) [K] | yes | yes (Buildah) [K] |
|
| Build images | no [D] | yes (BuildKit) [K] | yes | yes (Buildah) [K] |
|
||||||
| Host dirs into the container (`-v`) | **no** [D] | yes [K] | yes | yes |
|
| Host dirs into the container (`-v`) | yes, `--mount SRC:DST[:ro]`, not subject to the size cap; a `dav(s)://`/`http(s)://` source auto-mounts WebDAV via a managed `rclone` [D] | yes [K] | yes | yes |
|
||||||
| Published ports | none for `slirp`; automatic for `pasta` [D] | `-p` [K] | `-p` | `-p` [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
| Published ports | none for `slirp`; automatic for `pasta` [D] | `-p` [K] | `-p` | `-p` [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
||||||
| Rootless network backend | slirp4netns or pasta [D] | n/a | RootlessKit based [S: [Podman blog](https://blog.podman.io/2023/07/podman-performance-root-and-rootless/)] | pasta default since 5.0 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
| Rootless network backend | slirp4netns or pasta [D] | n/a | RootlessKit based [S: [Podman blog](https://blog.podman.io/2023/07/podman-performance-root-and-rootless/)] | pasta default since 5.0 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
||||||
| Resource limits | memory, pids, cpus, written data [D][M] | memory, cpu, pids, more [K] | needs cgroup v2 + systemd delegation [S: [Docker rootless tips](https://docs.docker.com/engine/security/rootless/tips/)] | needs cgroup v2 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
| Resource limits | memory, pids, cpus, written data [D][M] | memory, cpu, pids, more [K] | needs cgroup v2 + systemd delegation [S: [Docker rootless tips](https://docs.docker.com/engine/security/rootless/tips/)] | needs cgroup v2 [S: [podman-rootless(7)](https://man.archlinux.org/man/podman-rootless.7.en)] |
|
||||||
@@ -30,7 +30,7 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
|
|||||||
| Default capabilities | Docker's default set [D][M] | that set [S] | that set | a smaller set [S: [dev.to comparison](https://dev.to/mechcloud_academy/docker-vs-podman-an-in-depth-comparison-2025-2eia)] |
|
| Default capabilities | Docker's default set [D][M] | that set [S] | that set | a smaller set [S: [dev.to comparison](https://dev.to/mechcloud_academy/docker-vs-podman-an-in-depth-comparison-2025-2eia)] |
|
||||||
| Orchestration (Compose, Kubernetes) | none [D] | Compose, Swarm, Kubernetes via runtimes [K] | same | Compose, pods, `kube play`, systemd units [K] |
|
| Orchestration (Compose, Kubernetes) | none [D] | Compose, Swarm, Kubernetes via runtimes [K] | same | Compose, pods, `kube play`, systemd units [K] |
|
||||||
| Background containers, logs, `stop` | none: a container lives while its first `attach` command lives [D] | yes [K] | yes | yes |
|
| Background containers, logs, `stop` | none: a container lives while its first `attach` command lives [D] | yes [K] | yes | yes |
|
||||||
| Binary size | <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:shared_libs-->6<!--/bench--> shared libraries, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M] | CLI <!--bench:docker_size-->29.9 MiB<!--/bench-->, plus `dockerd` and `containerd` [M][K] | same | <!--bench:podman_size-->41.2 MiB<!--/bench--> [M] |
|
| Binary size | <!--bench:doos_size-->688 KiB<!--/bench-->, <!--bench:shared_libs-->6<!--/bench--> shared libraries, <!--bench:src_lines-->4,413<!--/bench--> lines of C++ [M] | CLI <!--bench:docker_size-->29.9 MiB<!--/bench-->, plus `dockerd` and `containerd` [M][K] | same | <!--bench:podman_size-->41.2 MiB<!--/bench--> [M] |
|
||||||
| Platforms | Linux only, tested on x86_64 only [D] | Linux, macOS, Windows (via a VM) [K] | Linux | Linux; macOS and Windows via a VM [K] |
|
| Platforms | Linux only, tested on x86_64 only [D] | Linux, macOS, Windows (via a VM) [K] | Linux | Linux; macOS and Windows via a VM [K] |
|
||||||
| Maturity | version 1.1, one test machine | very mature, huge ecosystem | mature | mature |
|
| Maturity | version 1.1, one test machine | very mature, huge ecosystem | mature | mature |
|
||||||
|
|
||||||
@@ -48,24 +48,24 @@ Versions compared: doos 1.1.1, Docker 29.1.3 (rootful daemon on this machine), P
|
|||||||
|
|
||||||
## 3. Measurements
|
## 3. Measurements
|
||||||
|
|
||||||
Method: a trivial command (`true`) started <!--bench:runs-->10<!--/bench--> times in a row after one warm-up run, average wall time per run, the Alpine image already present for every tool, one machine, one run set on <!--bench:bench_date-->2026-10-23<!--/bench-->. Versions: <!--bench:versions-->doos 1.1.2, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1<!--/bench-->. The numbers come from `scripts/bench.sh` (`make bench`); the charts and the tables below are generated from its output (`make charts`), so they cannot drift from each other.
|
Method: a trivial command (`true`) started <!--bench:runs-->10<!--/bench--> times in a row after one warm-up run, average wall time per run, the Alpine image already present for every tool, one machine, one run set on <!--bench:bench_date-->2026-10-25<!--/bench-->. Versions: <!--bench:versions-->doos 1.5.0, Docker 29.1.3, Podman 5.7.0, bubblewrap 0.11.1<!--/bench-->. The numbers come from `scripts/bench.sh` (`make bench`); the charts and the tables below are generated from its output (`make charts`), so they cannot drift from each other.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<!-- bench:startup:begin -->
|
<!-- bench:startup:begin -->
|
||||||
| Setup | Network | Time per run |
|
| Setup | Network | Time per run |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| bubblewrap (host /usr) | none | 8.4 ms |
|
| bubblewrap (host /usr) | none | 9.2 ms |
|
||||||
| doos Alpine image | none | 13.8 ms |
|
| doos Alpine image | none | 14.7 ms |
|
||||||
| doos host mode | none | 18.7 ms |
|
| doos host mode | none | 16.2 ms |
|
||||||
| Podman Alpine | none | 208 ms |
|
| Podman Alpine | none | 216 ms |
|
||||||
| Docker Alpine (rootful) | none | 344 ms |
|
| Docker Alpine (rootful) | none | 346 ms |
|
||||||
| bubblewrap (host /usr) | default | 7.4 ms |
|
| bubblewrap (host /usr) | default | 8 ms |
|
||||||
| doos Alpine image | default | 52.1 ms |
|
| doos Alpine image | default | 56.2 ms |
|
||||||
| doos host mode | default | 56.9 ms |
|
| doos host mode | default | 60 ms |
|
||||||
| doos Alpine, pasta | default | 64.6 ms |
|
| doos Alpine, pasta | default | 66 ms |
|
||||||
| Podman Alpine | default | 241 ms |
|
| Podman Alpine | default | 233 ms |
|
||||||
| Docker Alpine (rootful) | default | 436 ms |
|
| Docker Alpine (rootful) | default | 449 ms |
|
||||||
<!-- bench:startup:end -->
|
<!-- bench:startup:end -->
|
||||||
|
|
||||||
How to read this honestly:
|
How to read this honestly:
|
||||||
@@ -82,7 +82,7 @@ How to read this honestly:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| bubblewrap | 79 KiB (80,424 bytes) |
|
| bubblewrap | 79 KiB (80,424 bytes) |
|
||||||
| crun | 472 KiB (483,392 bytes) |
|
| crun | 472 KiB (483,392 bytes) |
|
||||||
| doos | 552 KiB (565,680 bytes) |
|
| doos | 688 KiB (704,512 bytes) |
|
||||||
| docker CLI | 29.9 MiB (31,374,336 bytes) |
|
| docker CLI | 29.9 MiB (31,374,336 bytes) |
|
||||||
| podman | 41.2 MiB (43,180,896 bytes) |
|
| podman | 41.2 MiB (43,180,896 bytes) |
|
||||||
<!-- bench:sizes:end -->
|
<!-- bench:sizes:end -->
|
||||||
@@ -105,8 +105,8 @@ The Podman figure is this machine's accumulated state, not a like-for-like compa
|
|||||||
**Where doos is better**
|
**Where doos is better**
|
||||||
|
|
||||||
- No daemon and no root. With Docker's default install, anyone who can talk to the daemon controls the machine [S]. doos has no such trust boundary: it can only do what the invoking user can do, plus what the `newuidmap` helper allows.
|
- No daemon and no root. With Docker's default install, anyone who can talk to the daemon controls the machine [S]. doos has no such trust boundary: it can only do what the invoking user can do, plus what the `newuidmap` helper allows.
|
||||||
- Tiny and auditable: <!--bench:doos_size-->552 KiB<!--/bench-->, <!--bench:src_lines-->3,445<!--/bench--> lines of C++ [M], one process tree, no background service, nothing listening.
|
- Tiny and auditable: <!--bench:doos_size-->688 KiB<!--/bench-->, <!--bench:src_lines-->4,413<!--/bench--> lines of C++ [M], one process tree, no background service, nothing listening.
|
||||||
- Instant: <!--bench:doos_range-->14 to 65 ms<!--/bench--> against <!--bench:docker_range-->344 to 436 ms<!--/bench--> for a rootful Docker run and <!--bench:podman_range-->208 to 241 ms<!--/bench--> for Podman [M].
|
- Instant: <!--bench:doos_range-->15 to 66 ms<!--/bench--> against <!--bench:docker_range-->346 to 449 ms<!--/bench--> for a rootful Docker run and <!--bench:podman_range-->216 to 233 ms<!--/bench--> for Podman [M].
|
||||||
- Safe defaults a user does not have to remember: Docker's default capability set, a seccomp filter, an allowlist of host `/etc`, a memory and pid cap, a written-data cap [D][M].
|
- Safe defaults a user does not have to remember: Docker's default capability set, a seccomp filter, an allowlist of host `/etc`, a memory and pid cap, a written-data cap [D][M].
|
||||||
- A "host mode" that reuses the host's installed tools (compilers, `apt` state) for throwaway shells with no image download [D].
|
- A "host mode" that reuses the host's installed tools (compilers, `apt` state) for throwaway shells with no image download [D].
|
||||||
- A size cap that freezes instead of filling the disk [D][M]. Docker's writable-layer size limit needs the overlay2 driver on XFS with project quotas [S: [Fabian Lee](https://fabianlee.org/2020/01/15/docker-use-overlay2-with-an-xfs-backing-filesystem-to-limit-rootfs-size/)].
|
- A size cap that freezes instead of filling the disk [D][M]. Docker's writable-layer size limit needs the overlay2 driver on XFS with project quotas [S: [Fabian Lee](https://fabianlee.org/2020/01/15/docker-use-overlay2-with-an-xfs-backing-filesystem-to-limit-rootfs-size/)].
|
||||||
@@ -114,7 +114,7 @@ The Podman figure is this machine's accumulated state, not a like-for-like compa
|
|||||||
**Where Docker is better**
|
**Where Docker is better**
|
||||||
|
|
||||||
- Everything around images: registries, `docker build`, layer caching, multi-arch, signing and scanning ecosystems [K]. doos cannot pull an arbitrary `nginx:1.27` from a registry.
|
- Everything around images: registries, `docker build`, layer caching, multi-arch, signing and scanning ecosystems [K]. doos cannot pull an arbitrary `nginx:1.27` from a registry.
|
||||||
- Host directories and devices into the container (`-v`, `--device`, GPUs). doos has no bind-mount option [D].
|
- Devices and GPUs (`--device`, `--gpus`); doos has neither [D]. Host directories do bind-mount (`--mount SRC:DST[:ro]`), and so does remote storage: `--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source auto-installs and drives `rclone` to mount a WebDAV share, something Docker itself has no built-in equivalent for either [D].
|
||||||
- Long-running services: background containers, restart policies, healthchecks, logs, `exec`, `stop` [K]. In doos a container exists only while its first `attach` command runs [D].
|
- Long-running services: background containers, restart policies, healthchecks, logs, `exec`, `stop` [K]. In doos a container exists only while its first `attach` command runs [D].
|
||||||
- Networking: user-defined networks, DNS between containers, published ports on any interface [K].
|
- Networking: user-defined networks, DNS between containers, published ports on any interface [K].
|
||||||
- Compose, Swarm and the Kubernetes ecosystem [K].
|
- Compose, Swarm and the Kubernetes ecosystem [K].
|
||||||
@@ -175,7 +175,7 @@ These are the gaps that matter when comparing, each next to where it lives. They
|
|||||||
| Gap | Where |
|
| Gap | Where |
|
||||||
|---|---|
|
|---|---|
|
||||||
| No OCI registry pull, no layers, no build | `src/image.cpp` (`Catalog`, `ImageStore`) |
|
| No OCI registry pull, no layers, no build | `src/image.cpp` (`Catalog`, `ImageStore`) |
|
||||||
| No host directory bind mounts, no devices, no GPU | `setupRoot` in `src/sandbox.cpp`, option parsing in `src/cli.cpp` |
|
| No devices, no GPU passthrough (host directory bind mounts exist: `--mount`) | `setupRoot` in `src/sandbox.cpp`, option parsing in `src/cli.cpp` |
|
||||||
| No background containers, `stop`, `exec` by name, logs | lifetime model in `Sandbox::start` / `Sandbox::watch` (container lives with its first command) |
|
| No background containers, `stop`, `exec` by name, logs | lifetime model in `Sandbox::start` / `Sandbox::watch` (container lives with its first command) |
|
||||||
| No user-defined networks, no `-p` | `src/network.cpp` |
|
| No user-defined networks, no `-p` | `src/network.cpp` |
|
||||||
| No Compose or Kubernetes integration | not present |
|
| No Compose or Kubernetes integration | not present |
|
||||||
@@ -183,11 +183,10 @@ These are the gaps that matter when comparing, each next to where it lives. They
|
|||||||
| Linux x86_64 tested only | `src/hardening.cpp` guards aarch64, never run there |
|
| Linux x86_64 tested only | `src/hardening.cpp` guards aarch64, never run there |
|
||||||
| seccomp is a denylist | `deniedSyscalls` in `src/hardening.cpp` |
|
| seccomp is a denylist | `deniedSyscalls` in `src/hardening.cpp` |
|
||||||
| Test coverage: one machine | `tests/run.sh` |
|
| Test coverage: one machine | `tests/run.sh` |
|
||||||
| `fsutil::mkdirs` follows a planted symlink at a container's path, so a symlink at `$DOOS_HOME/containers/<name>` redirects where that container's state is written (low severity on a single-user `$DOOS_HOME`; found by [`audit/CVE-2021-30465`](audit/CVE-2021-30465/README.md)) | `fsutil::mkdirs` in `src/util.cpp` |
|
|
||||||
|
|
||||||
## 8. Conclusion
|
## 8. Conclusion
|
||||||
|
|
||||||
doos is not a Docker replacement and does not try to be. It is a small, rootless, daemonless tool for **disposable or per-user Linux shells and sandboxes that start in tens of milliseconds, with safe defaults and hard caps already set**. Its strengths are the ones the measurements show: it is a <!--bench:doos_size-->552 KiB<!--/bench--> single binary, it starts in <!--bench:doos_range-->14 to 65 ms<!--/bench--> [M], it runs with no daemon and no root, it caps memory, processes, CPU and written data by default, and it verifies what it downloads.
|
doos is not a Docker replacement and does not try to be. It is a small, rootless, daemonless tool for **disposable or per-user Linux shells and sandboxes that start in tens of milliseconds, with safe defaults and hard caps already set**. Its strengths are the ones the measurements show: it is a <!--bench:doos_size-->688 KiB<!--/bench--> single binary, it starts in <!--bench:doos_range-->15 to 66 ms<!--/bench--> [M], it runs with no daemon and no root, it caps memory, processes, CPU and written data by default, and it verifies what it downloads.
|
||||||
|
|
||||||
Choose by the job:
|
Choose by the job:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
BUILD ?= build
|
BUILD ?= build
|
||||||
DOOS := $(BUILD)/doos
|
DOOS := $(BUILD)/doos
|
||||||
|
|
||||||
.PHONY: help build test audit check-docs bench charts docs release clean
|
.PHONY: help build test audit valgrind check-docs bench charts docs release clean
|
||||||
|
|
||||||
help: ## list the targets
|
help: ## list the targets
|
||||||
@grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | column -t -s "$$(printf '\t')"
|
@grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | column -t -s "$$(printf '\t')"
|
||||||
@@ -19,6 +19,9 @@ test: build ## end-to-end suite (real downloads, needs network, pasta, gpg, cgro
|
|||||||
audit: build ## run the CVE regression checks under audit/ against build/doos (concurrent, -j)
|
audit: build ## run the CVE regression checks under audit/ against build/doos (concurrent, -j)
|
||||||
$(MAKE) -C audit -j8 test DOOS=$(abspath $(DOOS))
|
$(MAKE) -C audit -j8 test DOOS=$(abspath $(DOOS))
|
||||||
|
|
||||||
|
valgrind: build ## memcheck against a fixed set of doos invocations (needs valgrind, see valgrind/README.md)
|
||||||
|
scripts/valgrind.sh $(abspath $(DOOS))
|
||||||
|
|
||||||
check-docs: ## every document links to every other, every relative link resolves
|
check-docs: ## every document links to every other, every relative link resolves
|
||||||
scripts/check-docs.sh
|
scripts/check-docs.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# doos
|
# doos
|
||||||
|
|
||||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -10,23 +10,15 @@ doos get_or_create [OPTIONS] NAME
|
|||||||
doos attach [OPTIONS] NAME [-- CMD...]
|
doos attach [OPTIONS] NAME [-- CMD...]
|
||||||
doos delete [--yes] NAME
|
doos delete [--yes] NAME
|
||||||
doos image list | add [--sha256 HEX] SOURCE [NAME] | delete [--yes] NAME
|
doos image list | add [--sha256 HEX] SOURCE [NAME] | delete [--yes] NAME
|
||||||
|
doos davmount list | umount [--yes] ID
|
||||||
doos apparmor # profile text for this binary (Ubuntu 24.04+)
|
doos apparmor # profile text for this binary (Ubuntu 24.04+)
|
||||||
```
|
```
|
||||||
|
|
||||||
Version: see `VERSION` (currently 1.1.3). Release history: `CHANGELOG.md`.
|
Version: see `VERSION`. Release history: [CHANGELOG.md](CHANGELOG.md). License: [MIT](LICENSE).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```
|
|
||||||
doos list
|
|
||||||
doos get_or_create [--net slirp|host|none] NAME
|
|
||||||
doos attach [--net slirp|host|none] NAME [-- CMD...]
|
|
||||||
doos delete [--yes] NAME
|
|
||||||
```
|
|
||||||
|
|
||||||
Build: `cmake -S . -B build && cmake --build build`. Test: `tests/run.sh`.
|
Build: `cmake -S . -B build && cmake --build build`. Test: `tests/run.sh`.
|
||||||
|
|
||||||
Requirements: Linux >= 5.11, `slirp4netns` (default network mode), `pasta` (only for `--net pasta`), `gnupg` (signature checks; optional), `uidmap` (`newuidmap`/`newgidmap`, for more than one id inside; falls back to one id), `curl`, `tar` and `sha256sum` (images). On Ubuntu 24.04+ (`kernel.apparmor_restrict_unprivileged_userns=1`) AppArmor must allow user namespaces for the
|
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), `unzip` and `fusermount3` (or `fusermount`; only for `--mount`/`--davmount` against a `dav://`/`davs://`/`http(s)://` source). 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:
|
binary. Generate a profile for the exact path doos is installed at, once:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -118,13 +110,31 @@ Notes, all measured:
|
|||||||
| `--net M` | `slirp` (outbound only, host loopback blocked), `pasta` (host-like, private sockets), `host`, `none` | slirp | 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 |
|
| `--ids X` | `single` or `subuid` | subuid if possible | fixed |
|
||||||
| `--hardening H` | `default` or `relaxed` | default | fixed |
|
| `--hardening H` | `default` or `relaxed` | default | fixed |
|
||||||
|
| `--i-understand` | required on every call that sets or uses `--hardening relaxed` or `--net host` | absent | fixed |
|
||||||
| `--size S` | writable data cap | 4g | changeable |
|
| `--size S` | writable data cap | 4g | changeable |
|
||||||
| `--memory S` | memory cap | 8g | changeable |
|
| `--memory S` | memory cap | 8g | changeable |
|
||||||
| `--pids N` | process/thread cap | 4096 | changeable |
|
| `--pids N` | process/thread cap | 4096 | changeable |
|
||||||
| `--cpus N` | cpu cap, e.g. `0.5`, `2` | all cores | changeable |
|
| `--cpus N` | cpu cap, e.g. `0.5`, `2` | all cores | changeable |
|
||||||
|
| `--mount SRC:DST[:ro]` | bind-mount host dir `SRC` at `DST` inside, repeatable (max 8) | none | changeable |
|
||||||
|
| `--mount URL DST[:ro]` / `--davmount URL DST[:ro]` | auto-mount a WebDAV `URL` (`dav://`, `davs://`, `http://`, `https://`) at `DST`, see below | none | changeable |
|
||||||
|
| `--dav-user USER` / `--dav-pass PASS` | credentials for the `--mount`/`--davmount` entry just given | none | changeable |
|
||||||
|
| `--dav-vendor NAME` | `other`\|`nextcloud`\|`owncloud`\|`sharepoint`\|`sharepoint-ntlm` for that entry | other | changeable |
|
||||||
|
| `--dav-insecure` | skip TLS certificate verification for that entry | off | 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`.
|
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`.
|
||||||
|
|
||||||
|
`--mount` gives a container extra storage without counting against `--size`. For a plain host directory: `doos attach --mount /some/host/dir:/data NAME` (`SRC` must exist and be a directory when the container starts; add `:ro` for read-only).
|
||||||
|
|
||||||
|
For remote storage, give a WebDAV URL instead of a host path and doos does the rest: it downloads, verifies and installs `rclone` once into its own managed directory under `$DOOS_HOME` (never the system, never your `PATH`), then drives it to mount the share on the host before the container starts:
|
||||||
|
|
||||||
|
```
|
||||||
|
doos attach --mount davs://user:pass@cloud.example.com/remote.php/webdav /data NAME
|
||||||
|
# or, keeping the password out of the URL and shell history:
|
||||||
|
doos attach --davmount https://cloud.example.com/remote.php/webdav /data --dav-user user --dav-pass pass --dav-vendor nextcloud NAME
|
||||||
|
```
|
||||||
|
|
||||||
|
`dav://` and `davs://` are shorthand for plain-http and https WebDAV; a bare `http://`/`https://` URL works the same way. The mount is deduplicated by target (URL + credentials + vendor) and reused across containers and attaches instead of remounting every time; it lives on the host, outside any container, and keeps running after the container exits (writes go through a short write-back cache, about a second, before they reach the server). See `doos davmount list` and `doos davmount umount [--yes] ID` to inspect or tear one down, and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for how it is verified and supervised.
|
||||||
|
|
||||||
## Limits, size cap and hardening
|
## 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.
|
- **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.
|
||||||
@@ -158,6 +168,11 @@ Changeable options apply the next time the container starts. Examples: `doos att
|
|||||||
| `state path must not contain ',' or ':'` | Set `DOOS_HOME` to a plain path. |
|
| `state path must not contain ',' or ':'` | Set `DOOS_HOME` to a plain path. |
|
||||||
| `container 'x' is running` on delete | Exit the attached command first. |
|
| `container 'x' is running` on delete | Exit the attached command first. |
|
||||||
| `container limit reached (64)` | Delete one first. |
|
| `container limit reached (64)` | Delete one first. |
|
||||||
|
| `davmount failed for URL: authentication failed` | Wrong `--dav-user`/`--dav-pass` (or credentials embedded in the URL); fix and retry. |
|
||||||
|
| `davmount failed for URL: cannot reach the server` | Check the URL, the server, and network/DNS from the host. |
|
||||||
|
| `davmount failed for URL: TLS certificate error` | Self-signed server: pass `--dav-insecure` if you trust it. |
|
||||||
|
| `davmount failed for URL: the server does not look like a WebDAV endpoint` | Wrong path or `--dav-vendor`; check the URL against the server's WebDAV docs. |
|
||||||
|
| `davmount needs 'unzip' installed` | Install `unzip` (only needed the first time rclone is installed). |
|
||||||
|
|
||||||
## Safety summary
|
## Safety summary
|
||||||
|
|
||||||
@@ -175,7 +190,7 @@ Details: [COMPARISON](COMPARISON.md) section 6.1.
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`.
|
`make help` lists the targets: `make build`, `make test` (end-to-end, needs network), `make audit` (named CVE regression checks, see [audit/README.md](audit/README.md)), `make valgrind` (memcheck against a fixed set of invocations, see [valgrind/README.md](valgrind/README.md)), `make bench` (re-measure startup, sizes and the cpu limit), `make charts` (rebuild the SVG charts and the generated numbers in the comparison), `make docs`, `make release KIND=patch MSG="..."`. Build and test details are in [AGENTS](AGENTS.md); the charts and how they are generated are in [COMPARISON](COMPARISON.md) section 3 and `docs/charts/`. CI (`.gitea/workflows/ci.yml`) runs `build`, `valgrind`, `test` and `audit` on every push; see [docs/CI.md](docs/CI.md) for what each needs and the one-time runner setup.
|
||||||
|
|
||||||
## Project docs
|
## Project docs
|
||||||
|
|
||||||
@@ -188,5 +203,7 @@ Details: [COMPARISON](COMPARISON.md) section 6.1.
|
|||||||
| [`AGENTS.md`](AGENTS.md) | Rules for AI agents, release workflow |
|
| [`AGENTS.md`](AGENTS.md) | Rules for AI agents, release workflow |
|
||||||
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
|
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
|
||||||
| [`audit/README.md`](audit/README.md) | Named CVE regression checks run against `build/doos` (`make audit`) |
|
| [`audit/README.md`](audit/README.md) | Named CVE regression checks run against `build/doos` (`make audit`) |
|
||||||
|
| [`valgrind/README.md`](valgrind/README.md) | Memcheck against a fixed set of invocations (`make valgrind`) |
|
||||||
|
| [`docs/CI.md`](docs/CI.md) | Gitea Actions: what runs where, runner setup |
|
||||||
|
|
||||||
Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first.
|
Not sure whether doos is the right tool? Read the conclusion of [COMPARISON](COMPARISON.md) first.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# doos specification
|
# doos specification
|
||||||
|
|
||||||
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md)
|
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) · [RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) · [CHANGELOG](CHANGELOG.md) · [CI](docs/CI.md)
|
||||||
|
|
||||||
Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together. How the code implements it: [ARCHITECTURE](docs/ARCHITECTURE.md). Measurements and remaining limits: [RESEARCH](docs/RESEARCH.md). How it differs from other tools: [COMPARISON](COMPARISON.md). Usage: [README](README.md).
|
Authoritative description of behavior and limits. Code constants live in `src/spec.hpp`; change both together. How the code implements it: [ARCHITECTURE](docs/ARCHITECTURE.md). Measurements and remaining limits: [RESEARCH](docs/RESEARCH.md). How it differs from other tools: [COMPARISON](COMPARISON.md). Usage: [README](README.md).
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@ Authoritative description of behavior and limits. Code constants live in `src/sp
|
|||||||
| `doos image add [--sha256 HEX] SOURCE [NAME]` | Installs an image. See Images. |
|
| `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 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`. |
|
| `doos image delete [--yes] NAME` | Dry run unless `--yes`. Refuses an image used by any container, even with `--yes`. |
|
||||||
|
| `doos davmount list` | Table of davmount targets this host currently manages: ID, STATE (mounted/stale), VENDOR, URL. |
|
||||||
|
| `doos davmount umount [--yes] ID` | Dry run unless `--yes`. Unmounts and removes that managed target; stops storage for any container still using it. |
|
||||||
|
|
||||||
`get-or-create` is accepted as an alias.
|
`get-or-create` is accepted as an alias.
|
||||||
|
|
||||||
@@ -26,13 +28,29 @@ Container options (accepted by `get_or_create` and `attach`):
|
|||||||
| `--image NAME` | Start from an image instead of the host system. Missing catalog images are fetched on demand. | none (host mode) | no |
|
| `--image NAME` | Start from an image instead of the host system. Missing catalog images are fetched on demand. | none (host mode) | no |
|
||||||
| `--net slirp\|pasta\|host\|none` | Network mode, see Networking. | slirp | no |
|
| `--net slirp\|pasta\|host\|none` | Network mode, see Networking. | slirp | no |
|
||||||
| `--ids single\|subuid` | Id mapping. | subuid when available, else single | 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 |
|
| `--hardening default\|relaxed` | `relaxed` = no capability drop, no seccomp filter, full readable host `/etc` (host mode). `relaxed` and `--net host` also require `--i-understand` on every `get_or_create` and `attach` call, and print a warning once given. | default | no |
|
||||||
|
| `--i-understand` | Confirmation flag (no value) for the reduced sandboxing described above. Without it, `--hardening relaxed` or `--net host` is an error. | absent | n/a |
|
||||||
| `--size SIZE` | Cap for what the container writes (`512m`, `8g`, `1t`; k/m/g/t). | 4g | yes |
|
| `--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 |
|
| `--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 |
|
| `--pids N` | Process and thread cap (`pids.max`). | 4096 | yes |
|
||||||
| `--cpus N` | CPU cap, decimals allowed (`0.5`, `2`); `0` = all cores. | 0 | yes |
|
| `--cpus N` | CPU cap, decimals allowed (`0.5`, `2`); `0` = all cores. | 0 | yes |
|
||||||
|
| `--mount SRC:DST[:ro]` | Bind-mount host directory `SRC` at `DST` inside the container, repeatable (max 8). Not subject to `--size`: it is host storage, not the container's writable layer. Replaces the whole mount list when given. | none | yes |
|
||||||
|
| `--mount URL DST[:ro]` / `--davmount URL DST[:ro]` | `URL` starting `dav://`, `davs://`, `http://` or `https://`: davmount, see below. Same list and cap as `--mount`. | none | yes |
|
||||||
|
| `--dav-user USER`, `--dav-pass PASS` | Credentials for the davmount entry just given (overrides any `user[:pass]@` in the URL). | none | yes |
|
||||||
|
| `--dav-vendor NAME` | `other`, `nextcloud`, `owncloud`, `sharepoint` or `sharepoint-ntlm` for that entry. | other | yes |
|
||||||
|
| `--dav-insecure` | Skip TLS certificate verification for that entry. | off | yes |
|
||||||
|
|
||||||
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.
|
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. A plain `--mount`'s `SRC` must exist and be a directory at the time the container starts; a missing `SRC` fails the start loudly. A davmount's URL is resolved and mounted automatically, see below.
|
||||||
|
|
||||||
|
## davmount
|
||||||
|
|
||||||
|
`--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source turns into a managed WebDAV mount instead of a plain bind: doos downloads, verifies and installs a pinned `rclone` build under `$DOOS_HOME/tools/rclone-VERSION/rclone` (never the system, never the caller's `PATH`; same trust model as the catalog images: a fixed version, fetched over https, checked against a GPG-signed `SHA256SUMS` and a key pinned by fingerprint), then drives it to mount the target on the host, before the container's namespaces are created, in `Sandbox::start`.
|
||||||
|
|
||||||
|
- **Identity and reuse**: a target is identified by URL + user + pass + vendor + the insecure flag (not by destination), hashed into a 16-hex-char id under `$DOOS_HOME/davmounts/<id>/{mnt,log,pid,meta,lock}`. The same target reused by another `--mount`/`--davmount` entry, in the same or a different container, is mounted once and reused; a dead or unreachable mount is detected (process liveness plus an active directory listing, since a FUSE mount answers `stat` on its root without ever reaching the backend) and remounted automatically.
|
||||||
|
- **On-the-fly, no config file**: rclone's connection-string syntax (`:webdav,url=...,vendor=...,user=...,pass=...:`), every field quoted; the password is passed through `rclone obscure` first (rclone's own documented requirement), never in plain text to the process, and never on its command line in a form `ps` would show in full (quoted into one argv element doos builds itself).
|
||||||
|
- **Lifecycle**: the mount runs as a detached, double-forked process (`setsid`, reparented to init) so it outlives the `doos attach` that created it; writes go through rclone's VFS write-back cache (`--vfs-cache-mode=writes`, required for WebDAV, which cannot stream; `--vfs-write-back=1s`, shortened from rclone's 5 s default) and reach the server shortly after a file is closed, independent of whether the container that wrote it has already exited.
|
||||||
|
- **Readiness and failure**: a fresh mount is given up to 20 s to answer a real directory listing (not just `stat`, which a FUSE root answers without a backend round trip); failure is classified from the log into one of: authentication, unreachable server, TLS certificate, not a WebDAV endpoint, or a generic fallback naming the log file. A failed attempt is unmounted but its log and directory are left for `doos davmount list`/`umount` to inspect or clear.
|
||||||
|
- **Not covered**: doos never implements WebDAV itself, and does not audit rclone's own implementation (see `audit/CVE-2020-7039` for the equivalent libslirp caveat on the networking side); a self-signed or misconfigured TLS endpoint needs `--dav-insecure` (an explicit, logged choice, not a default).
|
||||||
|
|
||||||
## Limits (hard caps)
|
## Limits (hard caps)
|
||||||
|
|
||||||
@@ -58,6 +76,10 @@ Changeable options apply at the next start of the container. Naming a fixed opti
|
|||||||
| slirp4netns ready wait | 5 s |
|
| slirp4netns ready wait | 5 s |
|
||||||
| Lock wait | 5 s |
|
| Lock wait | 5 s |
|
||||||
| Tree recursion depth | 256 |
|
| Tree recursion depth | 256 |
|
||||||
|
| `--mount`/`--davmount` entries | 8 per container |
|
||||||
|
| davmount targets managed at once | 32 |
|
||||||
|
| davmount ready wait | 20 s |
|
||||||
|
| rclone download | 128 MiB |
|
||||||
|
|
||||||
## State layout
|
## State layout
|
||||||
|
|
||||||
@@ -66,9 +88,14 @@ Changeable options apply at the next start of the container. Naming a fixed opti
|
|||||||
```
|
```
|
||||||
containers/NAME/{meta,pid,lock,seeded.*,rootfs/,layers/<dir>/{upper,work},private/{root,var,etc}}
|
containers/NAME/{meta,pid,lock,seeded.*,rootfs/,layers/<dir>/{upper,work},private/{root,var,etc}}
|
||||||
images/NAME/{meta,rootfs/}
|
images/NAME/{meta,rootfs/}
|
||||||
|
tools/rclone-VERSION/rclone
|
||||||
|
davmounts/ID/{mnt/,log,pid,meta,lock}
|
||||||
```
|
```
|
||||||
|
|
||||||
`meta` keys: `net created version image ids size`.
|
Container `meta` keys: `net created version image ids size memory pids cpus hardening`, then `mounts.count` and,
|
||||||
|
per entry, `mounts.N.kind mounts.N.src mounts.N.dst mounts.N.ro` (bind), plus `mounts.N.user mounts.N.pass
|
||||||
|
mounts.N.vendor mounts.N.insecure` (webdav; `pass` is plain text, see Safety rules). A container created under
|
||||||
|
1.5.0's single-line `mounts=` format is still read, bind entries only.
|
||||||
|
|
||||||
## Container filesystem, host mode (no `--image`)
|
## Container filesystem, host mode (no `--image`)
|
||||||
|
|
||||||
@@ -134,3 +161,5 @@ User, mount, pid, uts, ipc namespaces always; network namespace unless `host`. I
|
|||||||
- Unverifiable input is rejected: URL images without sha256, hash mismatches, bad names, bad state paths, missing slirp4netns or newuidmap when requested.
|
- 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`).
|
- Seeding and scans report what they skipped (`unreadable skipped: N`).
|
||||||
- A failed start kills the half-built container (no orphans); a failed image add or container create leaves nothing behind.
|
- A failed start kills the half-built container (no orphans); a failed image add or container create leaves nothing behind.
|
||||||
|
- `--dav-pass` (or a password embedded in a davmount URL) is stored in plain text in the container's `meta` file (0700 directory, owner-only, same trust level as everything else under `$DOOS_HOME`); it is never written to a log, never passed to `rclone` on its command line, and is run through `rclone obscure` only immediately before the mount process execs.
|
||||||
|
- `doos davmount umount` is dry-run by default, scoped to `$DOOS_HOME/davmounts`, never follows symlinks; unmounting a target in use stops storage for any container still mounting it (not prevented, only named in the dry-run output).
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2015-1328: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# CVE-2015-1328: overlayfs missing permission check on file creation in the upper directory (unprivileged userns)
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
Exploited in the wild (CISA KEV). Fixed: Ubuntu 14.10 3.16.0-41.55, 15.04 3.19.0-21.21, 14.04 3.13.0-55.92.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [Ubuntu advisory](https://ubuntu.com/security/CVE-2015-1328)
|
||||||
|
- [oss-security disclosure thread](https://openwall.com/lists/oss-security/2015/06/16/7)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
The oldest of the overlayfs-in-userns bugs in this audit: overlayfs did not check
|
||||||
|
permissions when creating files in the upper directory, reachable from CONFIG_USER_NS=y
|
||||||
|
kernels where overlayfs carries FS_USERNS_MOUNT, the default on the Ubuntu releases of
|
||||||
|
that era and the same precondition doos depends on today. Kept in the list to complete
|
||||||
|
the history of this bug class, not because current kernels are expected to be exposed.
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
Kernel version check against the three listed fixed builds; on any host from the last
|
||||||
|
several years this is expected to PASS trivially, included to complete the category.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
Same distro-backport blind spot as the newer overlayfs entries: a version string alone
|
||||||
|
cannot prove a specific vendor backport is present.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2015-1328: kernel version check against the three listed fixed builds. No exploit code.
|
||||||
|
set -u
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
full=$(uname -r)
|
||||||
|
major=$(echo "$full" | grep -oE '^[0-9]+')
|
||||||
|
[ -n "$major" ] || skip "could not parse kernel version from '$full'"
|
||||||
|
|
||||||
|
if [ "$major" -ge 4 ]; then
|
||||||
|
pass "kernel $full, major version 4 or newer, long past this 3.x-era fix"
|
||||||
|
fi
|
||||||
|
skip "kernel $full is on the 3.x branch this bug targeted; this check cannot tell a patched 3.13.0-55.92/3.16.0-41.55/3.19.0-21.21 point release from an unpatched one, check the Ubuntu advisory for the exact build"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2018-12020: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# CVE-2018-12020: GnuPG "SigSpoof": signature/decryption status spoofed via unescaped filename on --status-fd 2
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
Disclosed June 2018. Fixed in GnuPG 2.2.8 / 1.4.23.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [Help Net Security writeup](https://www.helpnetsecurity.com/2018/06/15/cve-2018-12020-digital-signature-spoofing/)
|
||||||
|
- [CERT-EU advisory](https://cert.europa.eu/publications/security-advisories/2018-016/markdown)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
Applications that ran GnuPG with `--status-fd 2` (merging machine-readable status lines
|
||||||
|
into stderr) and GnuPG's verbose mode (which echoes the input file's embedded name to
|
||||||
|
stderr without escaping newlines) could be fed a crafted message whose filename field
|
||||||
|
injected fake status lines, spoofing a GOODSIG result for an unsigned or badly signed
|
||||||
|
input. doos calls `gpg` to verify Alpine's and Ubuntu's release signatures
|
||||||
|
(`verifySignature` in `src/image.cpp`), so getting this invocation right is the one place
|
||||||
|
a bug in this class would matter: a spoofed GOODSIG here would make doos trust and
|
||||||
|
extract an unverified image.
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
Reads the exact `gpg` invocations in `src/image.cpp` for the two properties that defeat
|
||||||
|
this bug class: `--status-fd` must be a dedicated fd (not `2`, which would merge it with
|
||||||
|
stderr), and `--verbose` must not be passed. Source inspection, not a dynamic exploit
|
||||||
|
attempt: crafting a message that reproduces GnuPG's own newline-escaping bug would be
|
||||||
|
testing GnuPG, not doos, and depends on an unpatched GnuPG the audit does not control.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
A static check: it verifies the invocation in the source tree the check runs against,
|
||||||
|
not the behavior of whatever `gpg` binary happens to be installed. It also does not
|
||||||
|
prove there is no other way to smuggle data into the fd doos does read; it confirms the
|
||||||
|
one specific, named defeat (`--status-fd 2` plus `--verbose`) is not present.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2018-12020 (SigSpoof): src/image.cpp's gpg invocations must not combine --status-fd 2
|
||||||
|
# (merged with stderr) with --verbose (which echoes the unescaped filename to stderr).
|
||||||
|
set -u
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
SRC="$(cd "$(dirname "$0")/../.." && pwd)/src/image.cpp"
|
||||||
|
[ -f "$SRC" ] || skip "src/image.cpp not found at $SRC"
|
||||||
|
|
||||||
|
if grep -E 'gpg' "$SRC" | grep -qE -- '--status-fd["'"'"']?,?\s*["'"'"']?2\b'; then
|
||||||
|
fail "$SRC passes --status-fd 2 to gpg (merges status lines with stderr, the precondition for SigSpoof)"
|
||||||
|
fi
|
||||||
|
if grep -E 'gpg' "$SRC" | grep -q -- '--verbose'; then
|
||||||
|
fail "$SRC passes --verbose to gpg (echoes the unescaped input filename to stderr)"
|
||||||
|
fi
|
||||||
|
pass "$SRC does not combine --status-fd 2 with --verbose in any gpg invocation"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2019-16884: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# CVE-2019-16884: runc AppArmor confinement weakened by incorrect mount-target validation for /proc
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
CVSS 6.5-7.5. Fixed in runc 1.0.0-rc9.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [Rapid7 vulnerability record](https://www.rapid7.com/db/vulnerabilities/redhat-openshift-cve-2019-16884)
|
||||||
|
- [Debian security tracker](https://security-tracker.debian.org/tracker/CVE-2019-16884)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
runc's root filesystem setup validated a mount target incorrectly, letting a crafted
|
||||||
|
image mount something over `/proc` in a way that weakened the AppArmor profile runc
|
||||||
|
otherwise applies. doos has no AppArmor profile that constrains the container's own
|
||||||
|
filesystem view the way `docker-default` does (COMPARISON.md section 6 already states
|
||||||
|
this as a design difference, not a bug), so the specific confinement this CVE weakens
|
||||||
|
does not exist in doos to begin with. What is still worth checking directly is the thing
|
||||||
|
runc's bug actually let an attacker do: substitute a non-`proc` mount at the container's
|
||||||
|
`/proc`.
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
Attaches a container and checks, from inside, that `/proc`'s filesystem type (via
|
||||||
|
`/proc/self/mountinfo` on the real `/proc` doos mounted, read before the check's own
|
||||||
|
command could remount it) is `proc`, matching `doMount("proc", ..., "proc", ...)` in
|
||||||
|
`src/sandbox.cpp`.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
Confirms doos's own `/proc` mount is what it claims to be at container start; it does
|
||||||
|
not attempt the image-controlled mount-target substitution runc's bug needed, since doos
|
||||||
|
has no AppArmor profile for that substitution to bypass in the first place.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2019-16884-class: doos's own /proc mount must actually be of type proc.
|
||||||
|
set -u
|
||||||
|
DOOS="${1:-}"
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
export DOOS_HOME="$TMP/home"
|
||||||
|
|
||||||
|
out=$("$DOOS" attach --net none audit16884 -- sh -c '
|
||||||
|
awk "\$5==\"/proc\" { for (i=1;i<=NF;i++) if (\$i==\"-\") { print \$(i+1); exit } }" /proc/self/mountinfo
|
||||||
|
' 2>&1)
|
||||||
|
"$DOOS" delete --yes audit16884 >/dev/null 2>&1
|
||||||
|
|
||||||
|
fstype=$(echo "$out" | tail -1)
|
||||||
|
if [ "$fstype" != "proc" ]; then
|
||||||
|
fail "/proc inside the container is not a 'proc' mount: saw '$fstype' (raw: $out)"
|
||||||
|
fi
|
||||||
|
pass "/proc inside the container is a genuine proc mount"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2020-7039: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# CVE-2020-7039: libslirp/Slirp memory-safety bugs (heap overflow, use-after-free, OOB access) in the user-mode network stack
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
Red Hat RHSA-2020:0889 (Important), bundling CVE-2019-14378, CVE-2019-15890, CVE-2020-7039, CVE-2020-8608; related CVE-2020-10756 and CVE-2020-29130 fixed later in libslirp.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [Red Hat RHSA-2020:0889](https://access.redhat.com/errata/RHSA-2020:0889)
|
||||||
|
- [SUSE/openSUSE advisory for CVE-2020-10756 and CVE-2020-29130](https://tenable.com/plugins/nessus/159219)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
slirp4netns, doos's default network mode, links libslirp to provide user-mode TCP/IP.
|
||||||
|
Several memory-safety bugs were found and fixed in that code between 2019 and 2022: a
|
||||||
|
heap overflow, a use-after-free, and multiple out-of-bounds accesses while parsing
|
||||||
|
network traffic the sandboxed process generates or receives. Since slirp4netns runs as
|
||||||
|
the invoking user's own process (not root, not setuid), a bug here is a crash or, in the
|
||||||
|
worst case, code execution as that same user, not a privilege escalation, but it is the
|
||||||
|
one piece of doos's default path written in C and parsing attacker-reachable network
|
||||||
|
data, so it belongs in this audit's named list rather than being assumed fine by default.
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
Runs `slirp4netns --version` and reads its embedded `libslirp:` version line, compared
|
||||||
|
against the fixed releases for this CVE cluster (4.3.1 for the 2020 OOB/UAF bugs; the
|
||||||
|
2019 heap overflow was fixed earlier still). No exploit code: these are memory-corruption
|
||||||
|
bugs in a dependency, not something to trigger against the host running the check.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
`slirp4netns --version` reports the libslirp version it was built against; a
|
||||||
|
distribution that backports fixes without bumping that string would not be visible to
|
||||||
|
this check. Covers only `slirp4netns`/libslirp; `pasta` (passt) is a separate codebase
|
||||||
|
and is not covered by this entry.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2020-7039 (and the libslirp memory-safety cluster it shipped with): libslirp version
|
||||||
|
# against its fixed release (4.3.1). No exploit code.
|
||||||
|
set -u
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
command -v slirp4netns >/dev/null || skip "slirp4netns not installed"
|
||||||
|
out=$(slirp4netns --version 2>&1) || skip "'slirp4netns --version' failed: $out"
|
||||||
|
|
||||||
|
ver=$(echo "$out" | grep -oE 'libslirp: [0-9]+\.[0-9]+(\.[0-9]+)?' | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?')
|
||||||
|
[ -n "$ver" ] || skip "could not find a libslirp version in: $out"
|
||||||
|
|
||||||
|
major=$(echo "$ver" | cut -d. -f1); minor=$(echo "$ver" | cut -d. -f2); patch=$(echo "$ver" | cut -d. -f3); patch=${patch:-0}
|
||||||
|
|
||||||
|
if [ "$major" -gt 4 ]; then pass "libslirp $ver, past the 4.3.1 fix point"; fi
|
||||||
|
if [ "$major" -eq 4 ] && [ "$minor" -gt 3 ]; then pass "libslirp $ver, past the 4.3.1 fix point"; fi
|
||||||
|
if [ "$major" -eq 4 ] && [ "$minor" -eq 3 ] && [ "$patch" -ge 1 ]; then pass "libslirp $ver is at the fixed 4.3.1"; fi
|
||||||
|
fail "libslirp $ver is below the fixed 4.3.1 (CVE-2020-10756, CVE-2020-29130, and this cluster)"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2021-3493: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# CVE-2021-3493: Ubuntu OverlayFS privilege escalation via missing user-namespace fscap check
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
CVSS 7.8 (High). CISA KEV. Fixed in Ubuntu kernels 5.8.0-50.56 / 5.4.0-72.80 / 4.15.0-142.146 / 4.4.0-209.241.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [Wiz vulnerability database](https://www.wiz.io/vulnerability-database/cve/cve-2021-3493)
|
||||||
|
- [CircleCI CVE record](https://cve.circl.lu/vuln/CVE-2021-3493)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
Ubuntu's kernel carries a patch allowing unprivileged overlayfs mounts, which is exactly
|
||||||
|
what doos relies on for every attach (host mode overlays `/usr` and `/opt`, image mode
|
||||||
|
overlays an extracted rootfs). The kernel's overlayfs did not convert file capabilities
|
||||||
|
against the mounting user namespace before copy-up, so a crafted lower-layer file with a
|
||||||
|
capability xattr could grant that capability once copied into the upper layer and read
|
||||||
|
back outside the namespace, root-equivalent privileges from an ordinary unprivileged
|
||||||
|
overlay mount. This is the single most on-point kernel bug for doos's core design: it is
|
||||||
|
not "a container escape through some unrelated mechanism", it is the exact mount doos
|
||||||
|
issues on every `attach`.
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
Kernel version against the fixed builds (branch-aware: 5.8/5.4/4.15/4.4 have published
|
||||||
|
fixed point releases; anything on an unlisted branch between 4.4 and 5.11 is treated as
|
||||||
|
unproven and reported, not assumed safe). No exploit code: this is a kernel capability-set
|
||||||
|
bug, not something safe to trigger for real on the host running the check.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
This is the first of three overlayfs-in-userns kernel bugs in this audit (see also
|
||||||
|
`CVE-2023-2640`, `CVE-2023-32629`, `CVE-2015-1328`); together they are the strongest
|
||||||
|
argument for keeping the host kernel current, since doos's entire model depends on this
|
||||||
|
exact combination (overlayfs + unprivileged user namespace) staying safe.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2021-3493: kernel version against Ubuntu's fixed builds. No exploit code (kernel capability
|
||||||
|
# bug, not safe to trigger for real).
|
||||||
|
set -u
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
full=$(uname -r)
|
||||||
|
ver=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+')
|
||||||
|
[ -n "$ver" ] || skip "could not parse kernel version from '$full'"
|
||||||
|
major=$(echo "$ver" | cut -d. -f1); minor=$(echo "$ver" | cut -d. -f2); patch=$(echo "$ver" | cut -d. -f3)
|
||||||
|
abi=$(echo "$full" | grep -oE '\-[0-9]+' | head -1 | tr -d '-')
|
||||||
|
|
||||||
|
verge() { # verge a b c: true if running version (major.minor.patch) >= a.b.c
|
||||||
|
[ "$major" -gt "$1" ] && return 0; [ "$major" -lt "$1" ] && return 1
|
||||||
|
[ "$minor" -gt "$2" ] && return 0; [ "$minor" -lt "$2" ] && return 1
|
||||||
|
[ "$patch" -ge "$3" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$major" -gt 5 ] || { [ "$major" -eq 5 ] && [ "$minor" -ge 11 ]; }; then
|
||||||
|
pass "kernel $full is 5.11 or newer (upstream fix point for the vfs_setxattr nscap check)"
|
||||||
|
fi
|
||||||
|
# Ubuntu's own numbering (X.Y.Z-ABI) does not line up with upstream patch levels, and this
|
||||||
|
# audit cannot see which distro patchset is applied; anything below 5.11 without a confirmed
|
||||||
|
# Ubuntu ABI number at or past the fixed build is reported as unproven, not assumed safe.
|
||||||
|
fail "kernel $full is below the upstream fix point (5.11) and this check cannot confirm an Ubuntu backport is applied; verify against USN-4917-1 for this exact build (ABI ${abi:-unknown})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2022-0492: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# CVE-2022-0492: cgroup v1 release_agent write check missing, root-equivalent container escape
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
CVSS 7.0 (High). Fixed around Linux 5.17-rc3. Exploited in the wild before disclosure.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [The Hacker News coverage](https://thehackernews.com/2022/03/new-linux-kernel-cgroups-vulnerability.html)
|
||||||
|
- [Sysdig detection writeup](https://sysdig.com/blog/detecting-mitigating-cve-2021-0492-sysdig)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
`cgroup_release_agent_write` did not check that the writer held administrative privilege
|
||||||
|
over the cgroup hierarchy, so a process inside a container (even without CAP_SYS_ADMIN
|
||||||
|
at the host level, using a user-namespace-mounted cgroupfs) could set a cgroup v1
|
||||||
|
`release_agent` path and get it executed with host privileges the next time the cgroup
|
||||||
|
emptied. This is a cgroup v1 feature; doos targets cgroup v2 exclusively
|
||||||
|
(`src/cgroup.cpp`, `Cgroup::create`/`open` build paths under the unified hierarchy only)
|
||||||
|
and, more directly, never mounts any `/sys` or cgroupfs into the container at all, so the
|
||||||
|
file this bug needs to write would not exist inside a doos container regardless of
|
||||||
|
kernel version.
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
Attaches a container and checks `/sys/fs/cgroup` is absent (doos mounts no `/sys` inside
|
||||||
|
the container) and, if for some reason it is present, that no `release_agent` file exists
|
||||||
|
under it. FAIL only if a writable `release_agent` is actually reachable from inside.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
This verifies doos's own mount list, not the kernel: on a kernel still vulnerable to
|
||||||
|
CVE-2022-0492 but reached through some other route (a bind mount doos does not create),
|
||||||
|
this check says nothing. It is scoped to what `doos attach` itself can expose.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2022-0492: a doos container must not have cgroupfs (and therefore no release_agent file)
|
||||||
|
# mounted inside it at all, since doos mounts no /sys.
|
||||||
|
set -u
|
||||||
|
DOOS="${1:-}"
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
export DOOS_HOME="$TMP/home"
|
||||||
|
|
||||||
|
out=$("$DOOS" attach --net none audit0492 -- sh -c '
|
||||||
|
if [ -d /sys/fs/cgroup ]; then
|
||||||
|
echo "SYSFS_PRESENT"
|
||||||
|
[ -e /sys/fs/cgroup/release_agent ] && echo "RELEASE_AGENT_PRESENT:$( [ -w /sys/fs/cgroup/release_agent ] && echo writable || echo readonly)"
|
||||||
|
else
|
||||||
|
echo "NO_SYSFS"
|
||||||
|
fi
|
||||||
|
' 2>&1)
|
||||||
|
"$DOOS" delete --yes audit0492 >/dev/null 2>&1
|
||||||
|
|
||||||
|
if echo "$out" | grep -q 'RELEASE_AGENT_PRESENT:writable'; then
|
||||||
|
fail "a writable release_agent file is reachable inside the container: $out"
|
||||||
|
fi
|
||||||
|
if echo "$out" | grep -q NO_SYSFS; then
|
||||||
|
pass "no /sys/fs/cgroup inside the container at all (doos mounts no /sys): $out"
|
||||||
|
fi
|
||||||
|
pass "/sys/fs/cgroup present but no writable release_agent reachable: $out"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2022-27650: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# CVE-2022-27650: crun/Moby started containers with a non-empty inheritable capability set
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
CVSS 7.5. Fixed in crun 1.4.4; related Moby advisory GHSA-wr4f-w546-m398.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [Alpine security tracker](https://security.alpinelinux.org/vuln/CVE-2022-27650)
|
||||||
|
- [Debian security tracker](https://security-tracker.debian.org/CVE-2022-27650)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
crun (and, separately, Moby) could start a container process with inheritable
|
||||||
|
capabilities still set in its capability set, rather than cleared. A program inside the
|
||||||
|
container carrying file capabilities (`cap_setuid+ep` and similar) can raise an
|
||||||
|
inheritable capability into its permitted set across `execve`, turning a leftover
|
||||||
|
inheritable bit into a real privilege gain. doos drops to a fixed capability bounding
|
||||||
|
set in `Hardening::apply()` before exec; the inheritable set specifically is the thing
|
||||||
|
this bug class is about, and it is worth confirming directly rather than only checking
|
||||||
|
the effective set (which is what the README and `docs/RESEARCH.md` already measure via
|
||||||
|
`CapEff`).
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
Attaches a container with default hardening and reads `/proc/self/status`'s `CapInh`
|
||||||
|
line. Anything other than all-zero is FAIL.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
Only checks default hardening; `--hardening relaxed` is documented as removing capability
|
||||||
|
restrictions entirely and is out of scope here, same as the other hardening-dependent
|
||||||
|
checks in this audit.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2022-27650-class: the exec'd process's inheritable capability set must be empty under
|
||||||
|
# default hardening.
|
||||||
|
set -u
|
||||||
|
DOOS="${1:-}"
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
export DOOS_HOME="$TMP/home"
|
||||||
|
|
||||||
|
out=$("$DOOS" attach --net none audit27650 -- grep CapInh /proc/self/status 2>&1)
|
||||||
|
"$DOOS" delete --yes audit27650 >/dev/null 2>&1
|
||||||
|
|
||||||
|
capinh=$(echo "$out" | sed -n 's/^CapInh:[[:space:]]*//p' | tr -d '\t' | head -n1)
|
||||||
|
if [ -z "$capinh" ]; then
|
||||||
|
skip "could not read CapInh from the container: $out"
|
||||||
|
fi
|
||||||
|
if [ "$capinh" != "0000000000000000" ]; then
|
||||||
|
fail "CapInh is non-zero under default hardening: $out"
|
||||||
|
fi
|
||||||
|
pass "CapInh is all-zero under default hardening: $out"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2023-2640: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# CVE-2023-2640: GameOver(lay) part 1: Ubuntu OverlayFS skips capability conversion on copy-up
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
High severity. Wiz/ProjectDiscovery, 2023. Reported to affect ~40% of Ubuntu cloud workloads.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [Wiz blog: Ubuntu OverlayFS vulnerability](https://www.wiz.io/blog/ubuntu-overlayfs-vulnerability)
|
||||||
|
- [ProjectDiscovery writeup](https://blog.projectdiscovery.io/gameover-lay-local-privilege-escalation-in-ubuntu-kernel/)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
A second, later regression of the same bug class as `CVE-2021-3493` in Ubuntu's overlayfs
|
||||||
|
patch set: file security capabilities were not converted before copy-up, so a user
|
||||||
|
namespace with its own (locally scoped) admin-like capabilities could create a file that
|
||||||
|
carries root-equivalent capabilities once it reached a context without that namespace
|
||||||
|
restriction. Same relevance to doos as `CVE-2021-3493`: this is doos's core mount, not an
|
||||||
|
unrelated code path.
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
Kernel version/build check against the affected matrix Wiz published (Ubuntu 22.04 and
|
||||||
|
20.04 on kernel 5.15.0, Ubuntu 18.04 on kernel 5.4.0, as a work-in-progress matrix at
|
||||||
|
disclosure time). Reports FAIL for an exact match on the affected major.minor with no
|
||||||
|
newer point release info available to this script, PASS otherwise. No exploit code.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
Wiz's own affected-version matrix was explicitly marked a work in progress at
|
||||||
|
publication, and this check has no way to tell a patched point release of 5.15.0 from an
|
||||||
|
unpatched one from `uname -r` alone; treat FAIL here as "go check the Ubuntu security
|
||||||
|
notice for this exact build", not as a confirmed exploit.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2023-2640 (GameOver(lay) part 1): flags kernel major.minor versions Wiz listed as affected
|
||||||
|
# on Ubuntu. No exploit code.
|
||||||
|
set -u
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
full=$(uname -r)
|
||||||
|
mm=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+')
|
||||||
|
[ -n "$mm" ] || skip "could not parse kernel version from '$full'"
|
||||||
|
|
||||||
|
is_ubuntu=0
|
||||||
|
[ -r /etc/os-release ] && grep -qi ubuntu /etc/os-release && is_ubuntu=1
|
||||||
|
|
||||||
|
case "$mm" in
|
||||||
|
5.15|5.4)
|
||||||
|
if [ "$is_ubuntu" = 1 ]; then
|
||||||
|
fail "kernel $full matches a major.minor Wiz listed as affected on Ubuntu (5.15.0/5.4.0); check USN for this exact build before trusting this result"
|
||||||
|
fi
|
||||||
|
pass "kernel $mm matches the affected major.minor, but this is not Ubuntu (os-release does not mention it); Wiz's matrix was Ubuntu-specific"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
pass "kernel $full is not on the major.minor versions Wiz listed as affected ($full)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2023-28642: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# CVE-2023-28642: runc AppArmor/SELinux confinement bypass via a symlinked or non-directory /proc inside the container
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
Fixed in runc 1.1.5 (USN-6088-1), same release as CVE-2023-27561.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [Ubuntu Security Notice USN-6088-1](https://usn.ubuntu.com/6088-1)
|
||||||
|
- [LWN coverage](https://lwn.net/Articles/932370/)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
runc let `/proc` inside the container be a symlink, or let something be mounted over a
|
||||||
|
non-directory in its place, which bypassed its AppArmor and (on the right distro)
|
||||||
|
SELinux restrictions tied to the real `/proc`. doos has no comparable confinement layered
|
||||||
|
on top of `/proc` to bypass (same caveat as `CVE-2019-16884`: no per-container AppArmor
|
||||||
|
profile), but the underlying property, that `/proc` is a genuine, fresh directory mount
|
||||||
|
and not a symlink an image could have planted, is exactly the same `/proc` doos sets up
|
||||||
|
in `src/sandbox.cpp` and worth checking directly rather than only by analogy.
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
From inside a container, confirms `/proc` is a directory and not a symlink
|
||||||
|
(`[ -L /proc ]` must be false), on top of the mount-type check `CVE-2019-16884` already
|
||||||
|
runs.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
Checks the filesystem property the runc bug abused (a symlinked `/proc`), not the
|
||||||
|
AppArmor/SELinux bypass itself, since doos's hardening model (seccomp + capability drop,
|
||||||
|
not AppArmor confinement of the container's own filesystem) is a different mechanism;
|
||||||
|
see COMPARISON.md section 6 for that design difference.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2023-28642-class: /proc inside the container must be a real directory, not a symlink.
|
||||||
|
set -u
|
||||||
|
DOOS="${1:-}"
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
[ -x "$DOOS" ] || skip "no doos binary at '$DOOS'"
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
export DOOS_HOME="$TMP/home"
|
||||||
|
|
||||||
|
out=$("$DOOS" attach --net none audit28642 -- sh -c '[ -L /proc ] && echo SYMLINK || echo DIRECTORY' 2>&1)
|
||||||
|
"$DOOS" delete --yes audit28642 >/dev/null 2>&1
|
||||||
|
|
||||||
|
if echo "$out" | grep -q SYMLINK; then
|
||||||
|
fail "/proc inside the container is a symlink: $out"
|
||||||
|
fi
|
||||||
|
if echo "$out" | grep -q DIRECTORY; then
|
||||||
|
pass "/proc inside the container is a real directory, not a symlink"
|
||||||
|
fi
|
||||||
|
skip "could not determine /proc's type from the container: $out"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# CVE-2023-32629: see README.md in this directory.
|
||||||
|
DOOS ?= $(abspath ../../build/doos)
|
||||||
|
export DOOS
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@./reproduce.sh "$(DOOS)"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# CVE-2023-32629: GameOver(lay) part 2: Ubuntu OverlayFS skips permission checks on copy-up xattr
|
||||||
|
|
||||||
|
**Scope:** [audit](../README.md) · [README](../../README.md) · [COMPARISON](../../COMPARISON.md) · [docs/RESEARCH](../../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
High severity. Wiz/ProjectDiscovery, 2023.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- [Wiz blog: Ubuntu OverlayFS vulnerability](https://www.wiz.io/blog/ubuntu-overlayfs-vulnerability)
|
||||||
|
- [ProjectDiscovery writeup](https://blog.projectdiscovery.io/gameover-lay-local-privilege-escalation-in-ubuntu-kernel/)
|
||||||
|
|
||||||
|
## Relevance to doos
|
||||||
|
|
||||||
|
The second GameOver(lay) flaw: a separate skip of permission checks in overlayfs's
|
||||||
|
copy-up path when setting extended attributes, affecting a slightly different kernel
|
||||||
|
version range than `CVE-2023-2640` but the same end result. Same relevance to doos as the
|
||||||
|
other overlayfs entries: doos's attach path is exactly this mount.
|
||||||
|
|
||||||
|
|
||||||
|
## What the check does
|
||||||
|
|
||||||
|
Kernel version check against the versions ProjectDiscovery listed for this CVE on
|
||||||
|
Ubuntu. No exploit code.
|
||||||
|
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
Same limits as `CVE-2023-2640`: the published affected matrix is a snapshot, not a
|
||||||
|
definitive oracle for every point release; treat FAIL as a prompt to check the Ubuntu
|
||||||
|
security notice for the exact running build.
|
||||||
|
|
||||||
|
|
||||||
|
Run: `make test` in this directory, or `make -C audit test` from the repo root.
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CVE-2023-32629 (GameOver(lay) part 2): flags kernel major.minor versions reported affected
|
||||||
|
# on Ubuntu. No exploit code.
|
||||||
|
set -u
|
||||||
|
skip() { echo "SKIP: $*"; exit 0; }
|
||||||
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
pass() { echo "PASS: $*"; exit 0; }
|
||||||
|
|
||||||
|
full=$(uname -r)
|
||||||
|
mm=$(echo "$full" | grep -oE '^[0-9]+\.[0-9]+')
|
||||||
|
[ -n "$mm" ] || skip "could not parse kernel version from '$full'"
|
||||||
|
|
||||||
|
is_ubuntu=0
|
||||||
|
[ -r /etc/os-release ] && grep -qi ubuntu /etc/os-release && is_ubuntu=1
|
||||||
|
|
||||||
|
case "$mm" in
|
||||||
|
5.15|5.4|5.19)
|
||||||
|
if [ "$is_ubuntu" = 1 ]; then
|
||||||
|
fail "kernel $full matches a major.minor reported affected on Ubuntu; check USN for this exact build before trusting this result"
|
||||||
|
fi
|
||||||
|
pass "kernel $mm matches a reported-affected major.minor, but this is not Ubuntu"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
pass "kernel $full is not on a major.minor reported affected"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+11
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Scope:** [README](../README.md) · [AGENTS](../AGENTS.md) · [COMPARISON](../COMPARISON.md) · [docs/RESEARCH](../docs/RESEARCH.md)
|
**Scope:** [README](../README.md) · [AGENTS](../AGENTS.md) · [COMPARISON](../COMPARISON.md) · [docs/RESEARCH](../docs/RESEARCH.md)
|
||||||
|
|
||||||
A fixed, named set of 8 known CVEs relevant to the mechanisms doos uses (user namespaces,
|
A fixed, named set of 18 known CVEs relevant to the mechanisms doos uses (user namespaces,
|
||||||
overlayfs, tar extraction, abstract sockets, mount handling), each in its own directory with
|
overlayfs, tar extraction, abstract sockets, mount handling), each in its own directory with
|
||||||
the public source, why it matters to doos specifically, and a non-destructive regression check
|
the public source, why it matters to doos specifically, and a non-destructive regression check
|
||||||
against `build/doos`. This is not "every CVE" (that set is unbounded and mostly irrelevant); it
|
against `build/doos`. This is not "every CVE" (that set is unbounded and mostly irrelevant); it
|
||||||
@@ -57,5 +57,15 @@ here is a weaponized exploit for an unrelated target.
|
|||||||
| [CVE-2016-5195](CVE-2016-5195/) | Linux kernel `mm/gup.c` | doos runs on the host kernel; a kernel bug is a doos-host bug |
|
| [CVE-2016-5195](CVE-2016-5195/) | Linux kernel `mm/gup.c` | doos runs on the host kernel; a kernel bug is a doos-host bug |
|
||||||
| [CVE-2026-17106](CVE-2026-17106/) | moby/go-archive (Go), and tar extractors generally | `doos image add` extracts caller-supplied tarballs the same way |
|
| [CVE-2026-17106](CVE-2026-17106/) | moby/go-archive (Go), and tar extractors generally | `doos image add` extracts caller-supplied tarballs the same way |
|
||||||
| [CVE-2021-30465](CVE-2021-30465/) | runc (Go) mount target resolution | `fsutil::mkdirs` follows symlinks the same way `create_directories` does by default |
|
| [CVE-2021-30465](CVE-2021-30465/) | runc (Go) mount target resolution | `fsutil::mkdirs` follows symlinks the same way `create_directories` does by default |
|
||||||
|
| [CVE-2021-3493](CVE-2021-3493/) | Linux kernel overlayfs (`fs/overlayfs`) | doos mounts overlayfs inside an unprivileged user namespace on every attach |
|
||||||
|
| [CVE-2023-2640](CVE-2023-2640/) | Linux kernel overlayfs (Ubuntu patch set) | same mount doos issues on every attach |
|
||||||
|
| [CVE-2023-32629](CVE-2023-32629/) | Linux kernel overlayfs (Ubuntu patch set) | same mount doos issues on every attach |
|
||||||
|
| [CVE-2015-1328](CVE-2015-1328/) | Linux kernel overlayfs | same precondition doos depends on (overlayfs + unprivileged user namespace) |
|
||||||
|
| [CVE-2022-0492](CVE-2022-0492/) | Linux kernel `kernel/cgroup/cgroup-v1.c` | doos uses cgroup v2 only and mounts no /sys/cgroupfs into the container |
|
||||||
|
| [CVE-2022-27650](CVE-2022-27650/) | crun / Moby (Go/C) capability handling at container start | doos sets its own capability bounding set in `Hardening::apply()` |
|
||||||
|
| [CVE-2019-16884](CVE-2019-16884/) | runc (Go) mount-target validation | doos's own `/proc` must be a real proc mount, independent of any AppArmor profile |
|
||||||
|
| [CVE-2023-28642](CVE-2023-28642/) | runc (Go) /proc mount-target validation | doos's own /proc must be a real directory mount, not substitutable by an image |
|
||||||
|
| [CVE-2018-12020](CVE-2018-12020/) | GnuPG status-fd / verbose filename echoing | doos trusts gpg's verdict to decide whether to extract a downloaded image |
|
||||||
|
| [CVE-2020-7039](CVE-2020-7039/) | libslirp (C) packet parsing | slirp4netns, doos's default --net mode, links this library |
|
||||||
|
|
||||||
Back to [README](../README.md), [SPEC](../SPEC.md), [AGENTS](../AGENTS.md), [COMPARISON](../COMPARISON.md).
|
Back to [README](../README.md), [SPEC](../SPEC.md), [AGENTS](../AGENTS.md), [COMPARISON](../COMPARISON.md).
|
||||||
|
|||||||
+41
-6
@@ -1,6 +1,6 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md)
|
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
|
||||||
|
|
||||||
How doos works internally. The behavior contract is [SPEC](../SPEC.md); this explains the code. Why it was built this way and what was measured: [RESEARCH](RESEARCH.md); how it compares with other tools: [COMPARISON](../COMPARISON.md).
|
How doos works internally. The behavior contract is [SPEC](../SPEC.md); this explains the code. Why it was built this way and what was measured: [RESEARCH](RESEARCH.md); how it compares with other tools: [COMPARISON](../COMPARISON.md).
|
||||||
|
|
||||||
@@ -9,15 +9,16 @@ How doos works internally. The behavior contract is [SPEC](../SPEC.md); this exp
|
|||||||
| File | Class / role |
|
| File | Class / role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `src/spec.hpp` | `spec::` constants: caps, mount lists, device list, paths. Single source of truth with `SPEC.md`. |
|
| `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`, `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/util.{hpp,cpp}` | `SysError`, `Fd` (RAII fd), `FileLock` (bounded flock), `parseSize`/`formatSize`, `base64Decode`, `runProcess` (fork/exec, capped capture), `fetchText`/`fetchFile` (curl, https only, capped), `TempDir` (scratch dir under the state dir, removed on scope exit), `isHex64`, `verifyDetachedSignature`/`verifyClearsignedSignature` (gpg with a throwaway home, pinned fingerprint; shared by `image.cpp` and `davmount.cpp`), `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/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, debian resolution from publishers), `verifySignature` (gpg with a throwaway home, pinned fingerprint), `ImageStore` (add with download, sha256 and signature check, extraction; list; delete). |
|
| `src/image.{hpp,cpp}` | `Catalog` (alpine, ubuntu, debian resolution from publishers), `ImageStore` (add with download, sha256 and signature check via `util::verifyDetachedSignature`, extraction; list; delete). |
|
||||||
|
| `src/davmount.{hpp,cpp}` | `DavMount`: downloads/verifies/installs a pinned `rclone` under the state dir, resolves a `--mount`/`--davmount` WebDAV entry to a ready, reused, managed FUSE mount (on-the-fly connection string, double-fork daemonize, mountinfo + active-listing readiness, failure classification), `list`/`umount` for `doos davmount`. |
|
||||||
| `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/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/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/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/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/sandbox.{hpp,cpp}` | `Sandbox`: resolves davmounts (`DavMount::resolveAll`), starts a container (clone + id map + mounts + pivot_root + init), watches it (size cap), or joins a running one (setns). |
|
||||||
| `src/cli.{hpp,cpp}` | `Cli`: argument parsing, the four container commands and `image list/add/delete`. |
|
| `src/cli.{hpp,cpp}` | `Cli`: argument parsing, the four container commands, `image list/add/delete` and `davmount list/umount`. |
|
||||||
| `src/main.cpp` | Catches exceptions, prints `doos: <message>`, exit 1. |
|
| `src/main.cpp` | Catches exceptions, prints `doos: <message>`, exit 1. |
|
||||||
| `tests/run.sh` | End-to-end suite. |
|
| `tests/run.sh` | End-to-end suite. |
|
||||||
| `scripts/release.sh` | Bump, build, test, commit, tag. |
|
| `scripts/release.sh` | Bump, build, test, commit, tag. |
|
||||||
@@ -33,6 +34,7 @@ Cli::cmdAttach
|
|||||||
runningPid()? yes -> join() no -> start()
|
runningPid()? yes -> join() no -> start()
|
||||||
|
|
||||||
start():
|
start():
|
||||||
|
DavMount::resolveAll(mounts) before clone: webdav entries become ready, reused Bind entries
|
||||||
clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET]) child = container pid 1
|
clone(NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET]) child = container pid 1
|
||||||
IdMap::apply(child, ids) single: write /proc maps; subuid: newuidmap + newgidmap
|
IdMap::apply(child, ids) single: write /proc maps; subuid: newuidmap + newgidmap
|
||||||
slirp mode: SlirpSession.start(child) waits for ready-fd
|
slirp mode: SlirpSession.start(child) waits for ready-fd
|
||||||
@@ -71,7 +73,9 @@ Image mode:
|
|||||||
2. Mountpoints (`/proc /dev /tmp /run ...`) created inside the merged tree.
|
2. Mountpoints (`/proc /dev /tmp /run ...`) created inside the merged tree.
|
||||||
|
|
||||||
Both modes then: tmpfs `/tmp` and `/run`; `/dev` (tmpfs, bound host nodes, new devpts, `/dev/shm`, symlinks);
|
Both modes then: tmpfs `/tmp` and `/run`; `/dev` (tmpfs, bound host nodes, new devpts, `/dev/shm`, symlinks);
|
||||||
`/proc` with read-only remounts and masks from `spec.hpp`; `chdir(rootfs)`, `pivot_root(".", ".")`, lazy
|
`/proc` with read-only remounts and masks from `spec.hpp`; `mountExtra` binds each `Container::mounts()`
|
||||||
|
entry (`--mount SRC:DST[:ro]`) at `DST`, creating the mountpoint first (`fsutil::mkdirs`), read-only ones
|
||||||
|
remounted the same way `/proc`'s read-only paths are; `chdir(rootfs)`, `pivot_root(".", ".")`, lazy
|
||||||
unmount of the old root.
|
unmount of the old root.
|
||||||
|
|
||||||
## Seeding (host mode, once per container)
|
## Seeding (host mode, once per container)
|
||||||
@@ -84,6 +88,37 @@ unmount of the old root.
|
|||||||
|
|
||||||
Reasons are in `SPEC.md` (host root-owned files appear as `nobody`).
|
Reasons are in `SPEC.md` (host root-owned files appear as `nobody`).
|
||||||
|
|
||||||
|
## davmount flow
|
||||||
|
|
||||||
|
```
|
||||||
|
DavMount::resolveAll(mounts) (Sandbox::start, before clone)
|
||||||
|
for each WebDav entry:
|
||||||
|
id = fnv1a(url, user, pass, vendor, insecure) dst excluded: shared across containers
|
||||||
|
dir = $DOOS_HOME/davmounts/<id>/, mkdirs mnt/, FileLock dir/lock
|
||||||
|
already in mountinfo (fuse.*) and pidfile's pid alive and an active listing succeeds?
|
||||||
|
-> reuse mnt/ as is (sub-second)
|
||||||
|
-> else unmountQuiet (fusermount3 -u, then -uz if still mounted) and fall through
|
||||||
|
ensureRclone(): $DOOS_HOME/tools/rclone-VERSION/rclone; `rclone version` match -> reuse,
|
||||||
|
else download+verify (util::fetchFile, util::verifyClearsignedSignature against the
|
||||||
|
pinned key, sha256 against the signed SHA256SUMS), unzip, atomic rename into place
|
||||||
|
rclone obscure <pass> (only if a password was given)
|
||||||
|
connection string ":webdav,url=\"...\",vendor=\"...\"[,user=...][,pass=...][,no_check_certificate=true]:"
|
||||||
|
spawnRcloneMount: pipe, fork, fork again, setsid + redirect to dir/log, execve(rclone, mount, ...)
|
||||||
|
first fork writes the second fork's pid down the pipe, then exits (reaped immediately);
|
||||||
|
the real daemon is now a child of init, outliving this doos process entirely
|
||||||
|
poll up to kDavMountReadyTimeoutMs: daemon pid alive? mountinfo shows it AND a real directory
|
||||||
|
listing succeeds (stat() alone answers without touching the backend; only listing does)?
|
||||||
|
-> ready, write dir/pid + dir/meta (never the password)
|
||||||
|
-> daemon died or never answers in time: unmountQuiet, classify dir/log's tail
|
||||||
|
(401/Unauthorized, x509/certificate, 501/Unsupported method, dial/host/refused/timeout,
|
||||||
|
else "see the log"), throw
|
||||||
|
Bind entries pass through unchanged; every resolved entry becomes a plain Bind MountSpec
|
||||||
|
pointing at dir/mnt, so mountExtra() (sandbox.cpp) never has to know davmount exists.
|
||||||
|
```
|
||||||
|
|
||||||
|
`doos davmount list`/`umount` walk/remove `$DOOS_HOME/davmounts/*` directly; `umount` is dry-run unless
|
||||||
|
`--yes`, same pattern as `delete`/`image delete`.
|
||||||
|
|
||||||
## Images
|
## Images
|
||||||
|
|
||||||
`ImageStore::add`: lock-free staging dir `images/.tmp-NAME`, download with curl (https only, size and time caps),
|
`ImageStore::add`: lock-free staging dir `images/.tmp-NAME`, download with curl (https only, size and time caps),
|
||||||
|
|||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
# CI: Gitea Actions
|
||||||
|
|
||||||
|
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
|
||||||
|
|
||||||
|
The workflow is [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). It needs two kinds of
|
||||||
|
runner, because the thing doos tests is the thing most CI containers are built to prevent: a
|
||||||
|
process creating its own user, mount and pid namespaces and an overlayfs mount.
|
||||||
|
|
||||||
|
## What runs where
|
||||||
|
|
||||||
|
| Job | Runner | Needs | Proves |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `build` | any `docker://` runner (label `ubuntu-latest`) | a C++20 toolchain and CMake | the binary still compiles with `-Werror` and the documentation set is still fully cross-linked |
|
||||||
|
| `valgrind` | any `docker://` runner (label `ubuntu-latest`) | the above, plus `valgrind` | no new memory error or leak in doos's own code (see [valgrind/README.md](../valgrind/README.md)); this still gives a real answer even when the container's own AppArmor/seccomp policy blocks the user namespace doos tries to create, because `scripts/valgrind.sh` only fails on what valgrind itself reports, never on doos's own exit code |
|
||||||
|
| `test` | self-hosted, host mode, label `doos-host` | see below | the real end-to-end suite (`tests/run.sh`, currently 88 checks): real overlayfs, real namespaces, real downloads, real cgroup v2 limits |
|
||||||
|
| `audit` | self-hosted, host mode, label `doos-host` | see below | the named CVE regression set in [`audit/`](../audit/README.md) against the binary this run just built |
|
||||||
|
|
||||||
|
`build` and `valgrind` run in an ephemeral Docker-mode act_runner container and need nothing this
|
||||||
|
repository does not already ask of any build machine. `test` and `audit` need a real Linux host
|
||||||
|
(or VM) that act_runner reaches in *host mode* (a plain label, not a `docker://` one), because the
|
||||||
|
things under test are precisely the things a container runtime's own default policy restricts: an
|
||||||
|
unprivileged user namespace that creates an overlayfs mount, a systemd user manager with cgroup v2
|
||||||
|
delegated to it, and (on Ubuntu 24.04+) an AppArmor profile that permits user namespaces for this
|
||||||
|
one binary path. Running those two jobs inside another container would either need that container
|
||||||
|
privileged (defeating the point of isolating the runner) or would silently test less than it claims
|
||||||
|
to, the same vacuous-pass risk `review2.md` section 2 warns about for the audit script's own design.
|
||||||
|
|
||||||
|
## One-time host runner setup (by whoever administers the runner, not by the workflow)
|
||||||
|
|
||||||
|
The workflow never runs anything with `sudo`, on purpose (same rule as everywhere else in this
|
||||||
|
project: AppArmor and system changes are requested from a human, never done by an agent or a CI
|
||||||
|
job on its own). Before registering the `doos-host` runner:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt-get install -y build-essential cmake slirp4netns passt gnupg uidmap curl tar
|
||||||
|
# /etc/subuid and /etc/subgid must list the runner's own user; uidmap's postinst usually does this.
|
||||||
|
grep "^$(whoami):" /etc/subuid /etc/subgid
|
||||||
|
|
||||||
|
# cgroup v2 must be delegated to this user's systemd manager (loginctl enable-linger helps it
|
||||||
|
# survive without an interactive session):
|
||||||
|
loginctl enable-linger "$(whoami)"
|
||||||
|
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers
|
||||||
|
|
||||||
|
# Build doos once at the exact path the runner will use, then install its AppArmor profile
|
||||||
|
# (needed on Ubuntu 24.04+; see README's Troubleshooting table for the symptom this fixes):
|
||||||
|
cmake -S . -B build && cmake --build build
|
||||||
|
build/doos apparmor | sudo tee /etc/apparmor.d/doos-runner >/dev/null
|
||||||
|
sudo apparmor_parser -r /etc/apparmor.d/doos-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
Then register the runner itself (see
|
||||||
|
[Gitea's act_runner docs](https://docs.gitea.com/usage/actions/act-runner) for the current command
|
||||||
|
and token source):
|
||||||
|
|
||||||
|
```
|
||||||
|
./act_runner register --instance https://<your-gitea> --token <runner-token> \
|
||||||
|
--name doos-host --labels doos-host
|
||||||
|
./act_runner daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
A `docker://`-labelled runner for `build` and `valgrind` needs no special setup; any act_runner in
|
||||||
|
its default Docker mode works (the Gitea docs' own suggested registration labels, such as
|
||||||
|
`ubuntu-latest:docker://gitea/runner-images:ubuntu-latest`, are enough).
|
||||||
|
|
||||||
|
## Known Gitea Actions gaps versus GitHub Actions (checked against the 1.25 docs)
|
||||||
|
|
||||||
|
- **`timeout-minutes` and `concurrency` are parsed but ignored.** The workflow still sets
|
||||||
|
`timeout-minutes` on every job for readability and for anyone who later runs the same file
|
||||||
|
under real GitHub Actions, but the actual, enforced cap in each job is a shell-level
|
||||||
|
`timeout <seconds>` wrapped around the one command that matters, consistent with this project's
|
||||||
|
own rule that every automated process gets a hard, enforced ceiling, not a cap that only works
|
||||||
|
if the runner happens to honor it.
|
||||||
|
- **`permissions` support is version-dependent** and GitHub-only scopes (`checks`, `id-token`,
|
||||||
|
`security-events`, `pages`, ...) are not supported at all. The workflow does not rely on it.
|
||||||
|
- **`uses: actions/checkout@v4`** is resolved the same way GitHub Actions resolves it (Gitea
|
||||||
|
Actions proxies to GitHub by default); an instance configured without internet access to
|
||||||
|
GitHub needs `https://gitea.com/actions/checkout` instead, or a plain `git clone` step.
|
||||||
|
|
||||||
|
## Running it without Gitea
|
||||||
|
|
||||||
|
Every job is one or two `make` targets with a `timeout` in front; none of it depends on Gitea
|
||||||
|
Actions syntax beyond `on`/`jobs`/`runs-on`/`steps`, so the same file works, unmodified, as a
|
||||||
|
GitHub Actions workflow (just move it to `.github/workflows/`) with the one exception that
|
||||||
|
`timeout-minutes` would then actually be enforced by the platform instead of being a label only
|
||||||
|
(harmless either way).
|
||||||
|
|
||||||
|
Back to [README](../README.md), [AGENTS](../AGENTS.md), [SPEC](../SPEC.md).
|
||||||
+20
-2
@@ -1,9 +1,14 @@
|
|||||||
# Research: user-level containers with overlayfs
|
# Research: user-level containers with overlayfs
|
||||||
|
|
||||||
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md)
|
**Docs:** [README](../README.md) · [SPEC](../SPEC.md) · [ARCHITECTURE](ARCHITECTURE.md) · [RESEARCH](RESEARCH.md) · [COMPARISON](../COMPARISON.md) · [AGENTS](../AGENTS.md) · [CHANGELOG](../CHANGELOG.md) · [CI](CI.md)
|
||||||
|
|
||||||
Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1.3.3, uid 1000, no root at runtime.
|
Measured on this machine: Ubuntu, kernel 7.0.0-38, AppArmor 5.0.2, slirp4netns 1.3.3, uid 1000, no root at runtime.
|
||||||
|
|
||||||
|
Memory-safety of doos's own code (not its behavior) is checked separately with `make valgrind`
|
||||||
|
against a fixed set of invocations; see [valgrind/README.md](../valgrind/README.md) for what it
|
||||||
|
covers and its suppressions. Known CVEs in software doos depends on or resembles are tracked in
|
||||||
|
[audit/README.md](../audit/README.md) (`make audit`).
|
||||||
|
|
||||||
## What works unprivileged
|
## What works unprivileged
|
||||||
|
|
||||||
1. `clone(CLONE_NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET])`, then the parent writes `setgroups=deny`, `uid_map`, `gid_map` (single entry, own uid). No setuid helpers (`newuidmap`) needed.
|
1. `clone(CLONE_NEWUSER|NEWNS|NEWPID|NEWUTS|NEWIPC[|NEWNET])`, then the parent writes `setgroups=deny`, `uid_map`, `gid_map` (single entry, own uid). No setuid helpers (`newuidmap`) needed.
|
||||||
@@ -57,7 +62,20 @@ removed afterwards. Rows marked "unmeasured" have no run behind them.
|
|||||||
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
||||||
| G8 | `fsutil::mkdirs` follows a symlink planted at a container's path under `$DOOS_HOME/containers/<name>`, same bug class as [`audit/CVE-2021-30465`](../audit/CVE-2021-30465/README.md) | refuse or unlink-and-recreate when a path component is a pre-existing symlink | **unmeasured fix, confirmed gap** (found by `make audit`, 2026-10-23) | `fsutil::mkdirs` in `src/util.cpp` |
|
| G8 | `fsutil::mkdirs` followed a symlink planted at a container's path under `$DOOS_HOME/containers/<name>`, same bug class as [`audit/CVE-2021-30465`](../audit/CVE-2021-30465/README.md) | walk the path component by component from a pinned base fd with `mkdirat`/`openat(O_NOFOLLOW\|O_DIRECTORY)`, refusing any symlink instead of following it | **done in 1.3.0**, re-measured: `make -C audit/CVE-2021-30465 test` now PASSes | `fsutil::mkdirs` in `src/util.cpp` |
|
||||||
|
| G9 | `parseSize` left-shifted without an overflow check: `--size 16777217t` wrapped to exactly `1.0T` and was accepted instead of rejected | check `v > (UINT64_MAX >> shift)` before shifting, reject instead of wrapping | **done in 1.3.2**, reproduced before the fix, re-run after: rejected | `parseSize` in `src/util.cpp` |
|
||||||
|
| G10 | `newuidmap`/`newgidmap`/`slirp4netns`/`pasta` were pinned to a single hardcoded `/usr/bin/...` path, unlike `curl`/`tar`/`sha256sum`/`gpg` which go through `resolveBinary()`'s state-dir-override-then-fixed-dirs search; a distro installing them elsewhere would fail even though PATH-poisoning was never possible either way | route all four through `resolveBinary()`, same as the other helpers | **done in 1.3.2** | `idmap.cpp`, `network.cpp` |
|
||||||
|
| G11 | Alpine's `latest-releases.yaml` and Debian's `index.json` (embedded base64 manifest) are read with fixed string/byte-offset search, not a YAML/JSON library; a publisher reformatting either file breaks extraction with a thrown error (fails closed) rather than silently misreading it | none: a dependency to parse two small, publisher-controlled files is a worse trade than failing loudly when their format changes, given the project's own "fewer dependencies" rule | **accepted, not fixed**; this row exists so the trade-off is written down, not rediscovered | `Catalog::resolve` in `src/image.cpp` |
|
||||||
|
| G12 | No way to give a container storage that does not count against `--size` (remote/network storage, e.g. a WebDAV share); every writable path was either the host overlay (capped) or a private dir (capped) | `--mount SRC:DST[:ro]`: bind-mount an already-mounted host path into the container, outside the size watchdog's scan set (`Container::usedBytes` only scans `layers/`/`private/`, never `--mount` targets). doos itself never speaks a network filesystem protocol: a plain `--mount` bind needs the host path mounted already; see G13 for WebDAV specifically | **done in 1.5.0** (`--mount`, max 8 per container, changeable, applies at next start); missing `SRC` fails the start loudly | `Container::mounts`, `mountExtra` in `src/sandbox.cpp` |
|
||||||
|
| G13 | G12's `--mount` still needed the user to mount WebDAV themselves (e.g. `rclone mount`) before pointing `--mount` at the result; no in-box path to remote storage | `--mount`/`--davmount` with a `dav://`, `davs://`, `http://` or `https://` source: doos downloads, GPG-verifies and installs a pinned `rclone` build under `$DOOS_HOME/tools/` (never the system, never PATH), then drives it on the host (on-the-fly connection string, no config file; detached double-fork daemon so the mount outlives the `attach` that created it) and bind-mounts the result exactly like a G12 bind. Deduplicated and reused by target (url+creds+vendor), not by destination | **done**, measured below under "davmount" | `src/davmount.{hpp,cpp}`, `DavMount::resolveAll` called from `Sandbox::start` |
|
||||||
|
|
||||||
|
### davmount (G13, measured against a local `rclone serve webdav` fixture)
|
||||||
|
|
||||||
|
- First use (fresh `$DOOS_HOME`, no managed rclone yet): `rclone-v1.75.2-linux-amd64.zip` (~20 MiB) downloaded, `SHA256SUMS` GPG-verified against the pinned fingerprint (`gpg` here accepts the DSA/SHA-1 signature rclone's release process still uses; it is a key-import plus `GOODSIG`/`VALIDSIG` fingerprint match, the same check as Ubuntu's image signature, not the web-of-trust warning gpg also prints), sha256 matched, unzipped, installed; the whole install plus the first mount completed in a few seconds and only ever happens once per `$DOOS_HOME`.
|
||||||
|
- Reuse (second, unrelated container against the same target): measured at 41 ms and 45 ms for `attach -- true`, i.e. no remount cost at all; the existing FUSE mount is confirmed healthy (pid alive, mountinfo entry present, a real directory listing succeeds) and reused as is.
|
||||||
|
- Write durability: `--vfs-cache-mode=writes` (rclone's own recommendation for WebDAV, which cannot stream) queues a write for upload after `--vfs-write-back` (shortened here to 1 s from rclone's 5 s default). Measured: a file written from inside a container appeared on the backend about 5 s after the *default* write-back elapsed in one run; with `--vfs-write-back=1s` the same write reached the backend within about 1-2 s of the writing process exiting, confirmed independent of the container's own lifetime (the container had already exited; the detached rclone daemon kept running and finished the upload on its own).
|
||||||
|
- Failure classification, each reproduced against the same local fixture: wrong `--dav-pass` surfaced the server's `401 Unauthorized` and was reported as "authentication failed"; an unresolvable/unreachable address and a non-WebDAV endpoint (plain HTTP server, `501 Unsupported method ('PROPFIND')`) were reproduced in manual testing with the matching classifications. Every case failed within a few seconds, never the full 20 s readiness budget, because rclone mounts eagerly and only a real directory listing (not `stat` on the FUSE root, which always answers without touching the backend) forces the round trip that surfaces the error.
|
||||||
|
- `doos davmount list`/`umount --yes`: verified showing a live target's id/vendor/url and state, dry-run output naming the path and warning it stops storage for any container still using it, and `--yes` actually unmounting (`fusermount3 -u`) and removing the managed directory.
|
||||||
|
|
||||||
### G1: memory, cpu and pids limits (cgroup v2)
|
### G1: memory, cpu and pids limits (cgroup v2)
|
||||||
|
|
||||||
|
|||||||
+6
-110
@@ -1,121 +1,17 @@
|
|||||||
{
|
{
|
||||||
"meta": {
|
"meta": {
|
||||||
"date": "2026-10-23",
|
"date": "2026-10-25",
|
||||||
"kernel": "7.0.0-38-generic",
|
"kernel": "7.0.0-38-generic",
|
||||||
"cpus": 12,
|
"cpus": 12,
|
||||||
"runs": 10,
|
"runs": 10,
|
||||||
"doos": "1.1.2",
|
"doos": "1.5.0",
|
||||||
"podman": "podman version 5.7.0",
|
"podman": "podman version 5.7.0",
|
||||||
"docker": "Docker version 29.1.3, build 29.1.3-0ubuntu4.1",
|
"docker": "Docker version 29.1.3, build 29.1.3-0ubuntu4.1",
|
||||||
"bubblewrap": "bubblewrap 0.11.1",
|
"bubblewrap": "bubblewrap 0.11.1",
|
||||||
"src_lines": 3445,
|
"src_lines": 4413,
|
||||||
"shared_libs": 6
|
"shared_libs": 6
|
||||||
},
|
},
|
||||||
"startup": [
|
"startup": [{"id":"bwrap_none","label":"bubblewrap (host /usr)","group":"none","kind":"other","ms":9.2},{"id":"bwrap_net","label":"bubblewrap (host /usr)","group":"default","kind":"other","ms":8.0},{"id":"doos_host_none","label":"doos host mode","group":"none","kind":"doos","ms":16.2},{"id":"doos_alpine_none","label":"doos Alpine image","group":"none","kind":"doos","ms":14.7},{"id":"doos_host_slirp","label":"doos host mode","group":"default","kind":"doos","ms":60.0},{"id":"doos_alpine_slirp","label":"doos Alpine image","group":"default","kind":"doos","ms":56.2},{"id":"doos_alpine_pasta","label":"doos Alpine, pasta","group":"default","kind":"doos","ms":66.0},{"id":"podman_none","label":"Podman Alpine","group":"none","kind":"other","ms":216.0},{"id":"podman_net","label":"Podman Alpine","group":"default","kind":"other","ms":232.9},{"id":"docker_none","label":"Docker Alpine (rootful)","group":"none","kind":"other","ms":345.8},{"id":"docker_net","label":"Docker Alpine (rootful)","group":"default","kind":"other","ms":449.1}],
|
||||||
{
|
"sizes": [{"id":"bwrap","bytes":80424},{"id":"crun","bytes":483392},{"id":"doos","bytes":704512},{"id":"docker","bytes":31374336},{"id":"podman","bytes":43180896}],
|
||||||
"id": "bwrap_none",
|
"cpu": {"wall_seconds": 4.2, "busy_loops": 4, "unlimited": 16.32, "half_cpu": 2.29}
|
||||||
"label": "bubblewrap (host /usr)",
|
|
||||||
"group": "none",
|
|
||||||
"kind": "other",
|
|
||||||
"ms": 8.4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bwrap_net",
|
|
||||||
"label": "bubblewrap (host /usr)",
|
|
||||||
"group": "default",
|
|
||||||
"kind": "other",
|
|
||||||
"ms": 7.4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "doos_host_none",
|
|
||||||
"label": "doos host mode",
|
|
||||||
"group": "none",
|
|
||||||
"kind": "doos",
|
|
||||||
"ms": 18.7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "doos_alpine_none",
|
|
||||||
"label": "doos Alpine image",
|
|
||||||
"group": "none",
|
|
||||||
"kind": "doos",
|
|
||||||
"ms": 13.8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "doos_host_slirp",
|
|
||||||
"label": "doos host mode",
|
|
||||||
"group": "default",
|
|
||||||
"kind": "doos",
|
|
||||||
"ms": 56.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "doos_alpine_slirp",
|
|
||||||
"label": "doos Alpine image",
|
|
||||||
"group": "default",
|
|
||||||
"kind": "doos",
|
|
||||||
"ms": 52.1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "doos_alpine_pasta",
|
|
||||||
"label": "doos Alpine, pasta",
|
|
||||||
"group": "default",
|
|
||||||
"kind": "doos",
|
|
||||||
"ms": 64.6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "podman_none",
|
|
||||||
"label": "Podman Alpine",
|
|
||||||
"group": "none",
|
|
||||||
"kind": "other",
|
|
||||||
"ms": 207.8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "podman_net",
|
|
||||||
"label": "Podman Alpine",
|
|
||||||
"group": "default",
|
|
||||||
"kind": "other",
|
|
||||||
"ms": 241.2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "docker_none",
|
|
||||||
"label": "Docker Alpine (rootful)",
|
|
||||||
"group": "none",
|
|
||||||
"kind": "other",
|
|
||||||
"ms": 343.9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "docker_net",
|
|
||||||
"label": "Docker Alpine (rootful)",
|
|
||||||
"group": "default",
|
|
||||||
"kind": "other",
|
|
||||||
"ms": 435.6
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"sizes": [
|
|
||||||
{
|
|
||||||
"id": "bwrap",
|
|
||||||
"bytes": 80424
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "crun",
|
|
||||||
"bytes": 483392
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "doos",
|
|
||||||
"bytes": 565680
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "docker",
|
|
||||||
"bytes": 31374336
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "podman",
|
|
||||||
"bytes": 43180896
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cpu": {
|
|
||||||
"wall_seconds": 4.2,
|
|
||||||
"busy_loops": 4,
|
|
||||||
"unlimited": 16.26,
|
|
||||||
"half_cpu": 2.29
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -39,8 +39,8 @@
|
|||||||
<line class="grid" x1="670.0" y1="84" x2="670.0" y2="146"/>
|
<line class="grid" x1="670.0" y1="84" x2="670.0" y2="146"/>
|
||||||
<text class="tick" x="670.0" y="162" text-anchor="middle">20</text>
|
<text class="tick" x="670.0" y="162" text-anchor="middle">20</text>
|
||||||
<text class="lab" x="160" y="105.0" text-anchor="end">no limit</text>
|
<text class="lab" x="160" y="105.0" text-anchor="end">no limit</text>
|
||||||
<path class="oth" d="M170.0,92.0 h402.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-402.5 z"><title>no limit: 16.3 CPU-seconds</title></path>
|
<path class="oth" d="M170.0,92.0 h404.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-404.0 z"><title>no limit: 16.3 CPU-seconds</title></path>
|
||||||
<text class="val" x="584.5" y="105.0">16.3 CPU-s</text>
|
<text class="val" x="586.0" y="105.0">16.3 CPU-s</text>
|
||||||
<text class="lab" x="160" y="135.0" text-anchor="end">--cpus 0.5</text>
|
<text class="lab" x="160" y="135.0" text-anchor="end">--cpus 0.5</text>
|
||||||
<path class="acc" d="M170.0,122.0 h53.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-53.2 z"><title>--cpus 0.5: 2.3 CPU-seconds</title></path>
|
<path class="acc" d="M170.0,122.0 h53.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-53.2 z"><title>--cpus 0.5: 2.3 CPU-seconds</title></path>
|
||||||
<text class="val" x="235.2" y="135.0">2.3 CPU-s</text>
|
<text class="val" x="235.2" y="135.0">2.3 CPU-s</text>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -1,6 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 316" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 316" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
|
||||||
<title id="t">Size of the main executable</title>
|
<title id="t">Size of the main executable</title>
|
||||||
<desc id="d">Horizontal bars on a logarithmic axis: bubblewrap 79 KiB, crun (OCI runtime) 472 KiB, doos 552 KiB, docker CLI 29.9 MiB, podman 41.2 MiB.</desc>
|
<desc id="d">Horizontal bars on a logarithmic axis: bubblewrap 79 KiB, crun (OCI runtime) 472 KiB, doos 688 KiB, docker CLI 29.9 MiB, podman 41.2 MiB.</desc>
|
||||||
<style>
|
<style>
|
||||||
.bg { fill: var(--surface); }
|
.bg { fill: var(--surface); }
|
||||||
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
<path class="oth" d="M170.0,140.0 h140.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-140.3 z"><title>crun (OCI runtime): 472 KiB</title></path>
|
<path class="oth" d="M170.0,140.0 h140.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-140.3 z"><title>crun (OCI runtime): 472 KiB</title></path>
|
||||||
<text class="val" x="322.3" y="153.0">472 KiB</text>
|
<text class="val" x="322.3" y="153.0">472 KiB</text>
|
||||||
<text class="lab" x="160" y="183.0" text-anchor="end">doos</text>
|
<text class="lab" x="160" y="183.0" text-anchor="end">doos</text>
|
||||||
<path class="acc" d="M170.0,170.0 h150.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-150.4 z"><title>doos: 552 KiB</title></path>
|
<path class="acc" d="M170.0,170.0 h164.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-164.5 z"><title>doos: 688 KiB</title></path>
|
||||||
<text class="val" x="332.4" y="183.0">552 KiB</text>
|
<text class="val" x="346.5" y="183.0">688 KiB</text>
|
||||||
<text class="lab" x="160" y="213.0" text-anchor="end">docker CLI</text>
|
<text class="lab" x="160" y="213.0" text-anchor="end">docker CLI</text>
|
||||||
<path class="oth" d="M170.0,200.0 h408.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-408.5 z"><title>docker CLI: 29.9 MiB</title></path>
|
<path class="oth" d="M170.0,200.0 h408.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-408.5 z"><title>docker CLI: 29.9 MiB</title></path>
|
||||||
<text class="val" x="590.5" y="213.0">29.9 MiB</text>
|
<text class="val" x="590.5" y="213.0">29.9 MiB</text>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
+24
-24
@@ -1,6 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 502" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 502" width="760" role="img" aria-labelledby="t d" style="max-width:100%;height:auto">
|
||||||
<title id="t">Time to start a trivial command</title>
|
<title id="t">Time to start a trivial command</title>
|
||||||
<desc id="d">Horizontal bars, milliseconds, lower is better. doos: 14 to 65 ms; Podman: 208 to 241 ms; Docker: 344 to 436 ms; bubblewrap: 7 to 8 ms. Exact values are in the table in COMPARISON.md.</desc>
|
<desc id="d">Horizontal bars, milliseconds, lower is better. doos: 15 to 66 ms; Podman: 216 to 233 ms; Docker: 346 to 449 ms; bubblewrap: 8 to 9 ms. Exact values are in the table in COMPARISON.md.</desc>
|
||||||
<style>
|
<style>
|
||||||
.bg { fill: var(--surface); }
|
.bg { fill: var(--surface); }
|
||||||
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
.t1 { fill: var(--ink); font: 600 15px system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
</style>
|
</style>
|
||||||
<rect class="bg" x="0" y="0" width="760" height="502" rx="8"/>
|
<rect class="bg" x="0" y="0" width="760" height="502" rx="8"/>
|
||||||
<text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text>
|
<text class="t1" x="20" y="28">Time to start a trivial command (ms, lower is better)</text>
|
||||||
<text class="t2" x="20" y="46">10 runs each, average, warm cache, 2026-10-23, kernel 7.0.0-38-generic, 12 cores. Docker is a rootful daemon.</text>
|
<text class="t2" x="20" y="46">10 runs each, average, warm cache, 2026-10-25, kernel 7.0.0-38-generic, 12 cores. Docker is a rootful daemon.</text>
|
||||||
<rect class="acc" x="20" y="55" width="12" height="12" rx="2"/>
|
<rect class="acc" x="20" y="55" width="12" height="12" rx="2"/>
|
||||||
<text class="t2" x="38" y="65">doos</text>
|
<text class="t2" x="38" y="65">doos</text>
|
||||||
<rect class="oth" x="88" y="55" width="12" height="12" rx="2"/>
|
<rect class="oth" x="88" y="55" width="12" height="12" rx="2"/>
|
||||||
@@ -34,20 +34,20 @@
|
|||||||
<line class="grid" x1="576.0" y1="90" x2="576.0" y2="242"/>
|
<line class="grid" x1="576.0" y1="90" x2="576.0" y2="242"/>
|
||||||
<line class="grid" x1="670.0" y1="90" x2="670.0" y2="242"/>
|
<line class="grid" x1="670.0" y1="90" x2="670.0" y2="242"/>
|
||||||
<text class="lab" x="190" y="111.0" text-anchor="end">bubblewrap (host /usr)</text>
|
<text class="lab" x="190" y="111.0" text-anchor="end">bubblewrap (host /usr)</text>
|
||||||
<path class="oth" d="M200.0,98.0 h3.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.9 z"><title>bubblewrap (host /usr): 8.4 ms</title></path>
|
<path class="oth" d="M200.0,98.0 h4.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-4.6 z"><title>bubblewrap (host /usr): 9.2 ms</title></path>
|
||||||
<text class="val" x="215.9" y="111.0">8.4 ms</text>
|
<text class="val" x="216.6" y="111.0">9.2 ms</text>
|
||||||
<text class="lab" x="190" y="141.0" text-anchor="end">doos Alpine image</text>
|
<text class="lab" x="190" y="141.0" text-anchor="end">doos Alpine image</text>
|
||||||
<path class="acc" d="M200.0,128.0 h9.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-9.0 z"><title>doos Alpine image: 13.8 ms</title></path>
|
<path class="acc" d="M200.0,128.0 h9.8 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-9.8 z"><title>doos Alpine image: 14.7 ms</title></path>
|
||||||
<text class="val" x="221.0" y="141.0">13.8 ms</text>
|
<text class="val" x="221.8" y="141.0">14.7 ms</text>
|
||||||
<text class="lab" x="190" y="171.0" text-anchor="end">doos host mode</text>
|
<text class="lab" x="190" y="171.0" text-anchor="end">doos host mode</text>
|
||||||
<path class="acc" d="M200.0,158.0 h13.6 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-13.6 z"><title>doos host mode: 18.7 ms</title></path>
|
<path class="acc" d="M200.0,158.0 h11.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-11.2 z"><title>doos host mode: 16.2 ms</title></path>
|
||||||
<text class="val" x="225.6" y="171.0">18.7 ms</text>
|
<text class="val" x="223.2" y="171.0">16.2 ms</text>
|
||||||
<text class="lab" x="190" y="201.0" text-anchor="end">Podman Alpine</text>
|
<text class="lab" x="190" y="201.0" text-anchor="end">Podman Alpine</text>
|
||||||
<path class="oth" d="M200.0,188.0 h191.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-191.3 z"><title>Podman Alpine: 208 ms</title></path>
|
<path class="oth" d="M200.0,188.0 h199.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-199.0 z"><title>Podman Alpine: 216 ms</title></path>
|
||||||
<text class="val" x="403.3" y="201.0">208 ms</text>
|
<text class="val" x="411.0" y="201.0">216 ms</text>
|
||||||
<text class="lab" x="190" y="231.0" text-anchor="end">Docker Alpine (rootful)</text>
|
<text class="lab" x="190" y="231.0" text-anchor="end">Docker Alpine (rootful)</text>
|
||||||
<path class="oth" d="M200.0,218.0 h319.3 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-319.3 z"><title>Docker Alpine (rootful): 344 ms</title></path>
|
<path class="oth" d="M200.0,218.0 h321.1 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-321.1 z"><title>Docker Alpine (rootful): 346 ms</title></path>
|
||||||
<text class="val" x="531.3" y="231.0">344 ms</text>
|
<text class="val" x="533.1" y="231.0">346 ms</text>
|
||||||
<line class="axis" x1="200" y1="90" x2="200" y2="242"/>
|
<line class="axis" x1="200" y1="90" x2="200" y2="242"/>
|
||||||
<text class="tick" x="200.0" y="256" text-anchor="middle">0</text>
|
<text class="tick" x="200.0" y="256" text-anchor="middle">0</text>
|
||||||
<text class="tick" x="294.0" y="256" text-anchor="middle">100</text>
|
<text class="tick" x="294.0" y="256" text-anchor="middle">100</text>
|
||||||
@@ -63,23 +63,23 @@
|
|||||||
<line class="grid" x1="576.0" y1="292" x2="576.0" y2="474"/>
|
<line class="grid" x1="576.0" y1="292" x2="576.0" y2="474"/>
|
||||||
<line class="grid" x1="670.0" y1="292" x2="670.0" y2="474"/>
|
<line class="grid" x1="670.0" y1="292" x2="670.0" y2="474"/>
|
||||||
<text class="lab" x="190" y="313.0" text-anchor="end">bubblewrap (host /usr)</text>
|
<text class="lab" x="190" y="313.0" text-anchor="end">bubblewrap (host /usr)</text>
|
||||||
<path class="oth" d="M200.0,300.0 h3.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.0 z"><title>bubblewrap (host /usr): 7.4 ms</title></path>
|
<path class="oth" d="M200.0,300.0 h3.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-3.5 z"><title>bubblewrap (host /usr): 8 ms</title></path>
|
||||||
<text class="val" x="215.0" y="313.0">7.4 ms</text>
|
<text class="val" x="215.5" y="313.0">8 ms</text>
|
||||||
<text class="lab" x="190" y="343.0" text-anchor="end">doos Alpine image</text>
|
<text class="lab" x="190" y="343.0" text-anchor="end">doos Alpine image</text>
|
||||||
<path class="acc" d="M200.0,330.0 h45.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-45.0 z"><title>doos Alpine image: 52.1 ms</title></path>
|
<path class="acc" d="M200.0,330.0 h48.8 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-48.8 z"><title>doos Alpine image: 56.2 ms</title></path>
|
||||||
<text class="val" x="257.0" y="343.0">52.1 ms</text>
|
<text class="val" x="260.8" y="343.0">56.2 ms</text>
|
||||||
<text class="lab" x="190" y="373.0" text-anchor="end">doos host mode</text>
|
<text class="lab" x="190" y="373.0" text-anchor="end">doos host mode</text>
|
||||||
<path class="acc" d="M200.0,360.0 h49.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-49.5 z"><title>doos host mode: 56.9 ms</title></path>
|
<path class="acc" d="M200.0,360.0 h52.4 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-52.4 z"><title>doos host mode: 60 ms</title></path>
|
||||||
<text class="val" x="261.5" y="373.0">56.9 ms</text>
|
<text class="val" x="264.4" y="373.0">60 ms</text>
|
||||||
<text class="lab" x="190" y="403.0" text-anchor="end">doos Alpine, pasta</text>
|
<text class="lab" x="190" y="403.0" text-anchor="end">doos Alpine, pasta</text>
|
||||||
<path class="acc" d="M200.0,390.0 h56.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-56.7 z"><title>doos Alpine, pasta: 64.6 ms</title></path>
|
<path class="acc" d="M200.0,390.0 h58.0 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-58.0 z"><title>doos Alpine, pasta: 66 ms</title></path>
|
||||||
<text class="val" x="268.7" y="403.0">64.6 ms</text>
|
<text class="val" x="270.0" y="403.0">66 ms</text>
|
||||||
<text class="lab" x="190" y="433.0" text-anchor="end">Podman Alpine</text>
|
<text class="lab" x="190" y="433.0" text-anchor="end">Podman Alpine</text>
|
||||||
<path class="oth" d="M200.0,420.0 h222.7 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-222.7 z"><title>Podman Alpine: 241 ms</title></path>
|
<path class="oth" d="M200.0,420.0 h214.9 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-214.9 z"><title>Podman Alpine: 233 ms</title></path>
|
||||||
<text class="val" x="434.7" y="433.0">241 ms</text>
|
<text class="val" x="426.9" y="433.0">233 ms</text>
|
||||||
<text class="lab" x="190" y="463.0" text-anchor="end">Docker Alpine (rootful)</text>
|
<text class="lab" x="190" y="463.0" text-anchor="end">Docker Alpine (rootful)</text>
|
||||||
<path class="oth" d="M200.0,450.0 h405.5 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-405.5 z"><title>Docker Alpine (rootful): 436 ms</title></path>
|
<path class="oth" d="M200.0,450.0 h418.2 a4,4 0 0 1 4,4 v10.0 a4,4 0 0 1 -4,4 h-418.2 z"><title>Docker Alpine (rootful): 449 ms</title></path>
|
||||||
<text class="val" x="617.5" y="463.0">436 ms</text>
|
<text class="val" x="630.2" y="463.0">449 ms</text>
|
||||||
<line class="axis" x1="200" y1="292" x2="200" y2="474"/>
|
<line class="axis" x1="200" y1="292" x2="200" y2="474"/>
|
||||||
<text class="tick" x="200.0" y="488" text-anchor="middle">0</text>
|
<text class="tick" x="200.0" y="488" text-anchor="middle">0</text>
|
||||||
<text class="tick" x="294.0" y="488" text-anchor="middle">100</text>
|
<text class="tick" x="294.0" y="488" text-anchor="middle">100</text>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
+150
@@ -0,0 +1,150 @@
|
|||||||
|
# doos: full review
|
||||||
|
|
||||||
|
Date: 2026-10-24 | Scope: all of `src/` (3,558 lines, read in full), `CMakeLists.txt`, `Makefile`,
|
||||||
|
`scripts/*.sh`, `tests/run.sh`, `audit/`, `valgrind/`, and every document (`README.md`, `SPEC.md`,
|
||||||
|
`docs/ARCHITECTURE.md`, `docs/RESEARCH.md`, `COMPARISON.md`, `AGENTS.md`, `CHANGELOG.md`).
|
||||||
|
Method: read every source file top to bottom (not a diff), ran the real e2e suite, the CVE audit,
|
||||||
|
and valgrind against the current binary, and tested two inputs by hand that looked suspicious while
|
||||||
|
reading. This is a second, independent pass; [`review.md`](review.md) is an earlier review whose
|
||||||
|
findings are checked against the current code, not reused.
|
||||||
|
|
||||||
|
**State at review time:** `v1.3.1`, working tree clean, `tests/run.sh` 88/88, `make audit` 18/18,
|
||||||
|
`make valgrind` 6/6 (see those tools for what they do and do not cover).
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
doos is a small, coherent, unusually well-specified codebase for what it is: a single C++20 binary
|
||||||
|
that builds a user-namespace container by hand (no runc, no libcontainer, no OCI anything) and
|
||||||
|
backs it with overlayfs, cgroup v2 and a seccomp denylist. The code reads like it was written by one
|
||||||
|
disciplined author following one set of rules consistently: every constant lives in `spec.hpp`, every
|
||||||
|
destructive path is capped, every external process is run from a fixed path, every error throws with
|
||||||
|
a message that says what to do about it. The project-level discipline (docs that state gaps next to
|
||||||
|
the code that closes them, a changelog per release, a `make audit`/`make valgrind` pair that produces
|
||||||
|
actual evidence instead of claims) is better than most projects an order of magnitude larger.
|
||||||
|
|
||||||
|
The flip side of "one author, one style" is that nothing here has been read by a second pair of eyes
|
||||||
|
until review.md and this review. I found one confirmed, reproducible bug (an integer overflow that
|
||||||
|
silently accepts a nonsensical `--size`) and a handful of smaller inconsistencies and gaps, listed
|
||||||
|
below. Nothing I found is a new security hole in the model doos is actually built on; the shared-kernel,
|
||||||
|
denylist-seccomp, unaudited-code risks are already disclosed in `COMPARISON.md` section 6 and
|
||||||
|
`docs/RESEARCH.md`, and I did not find reason to soften or harden that disclosure.
|
||||||
|
|
||||||
|
**Resolution (1.3.2, same day):** 2.1, 2.2 and 2.4 are fixed (see `CHANGELOG.md`); 2.3 is logged as
|
||||||
|
an accepted trade-off (gap G11 in `docs/RESEARCH.md`) rather than fixed; 2.5's CI half is resolved
|
||||||
|
by `.gitea/workflows/ci.yml` and `docs/CI.md`, and its LICENSE half by the new `LICENSE` file. The
|
||||||
|
findings below are left exactly as first written, for the record of what was found and how.
|
||||||
|
|
||||||
|
## 2. Findings
|
||||||
|
|
||||||
|
Ordered most to least severe. Each is something I read or reproduced, not a hypothetical.
|
||||||
|
|
||||||
|
### 2.1 [MEDIUM] `parseSize` silently overflows on an absurd input, instead of rejecting it
|
||||||
|
|
||||||
|
`src/util.cpp:54-71`. The function accepts up to 15 digits, then left-shifts by up to 40 bits with no
|
||||||
|
overflow check. `--size 16777217t` (16.7 million exabytes, obvious garbage) wraps around 64-bit
|
||||||
|
arithmetic to exactly `2^40`, which is `1.0T`, which is a *valid* size, so it is accepted silently:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ doos get_or_create --size 16777217t sizetest2
|
||||||
|
created sizetest2
|
||||||
|
$ doos list
|
||||||
|
NAME STATE NET IMAGE SIZE AGE
|
||||||
|
sizetest2 stopped slirp host 1.0T 0s
|
||||||
|
```
|
||||||
|
|
||||||
|
Reproduced against `build/doos` at the time of this review. The same shift pattern exists for `k`/`m`/`g`
|
||||||
|
too, just with smaller multipliers that need more digits to wrap; `t` wraps with an 8-digit input. Every
|
||||||
|
caller does bound-check the *result*, which is why most overflowing inputs are still rejected (the
|
||||||
|
earlier example, `16777217t`+1 lands outside range and throws): it is specifically the inputs that
|
||||||
|
wrap into the valid `[64m, 1t]` window that go through unnoticed. Fix: check `v > (kMaxSizeBytes >>
|
||||||
|
shift)` before shifting, or reject more than 4-5 digits before a `t`/`g` suffix.
|
||||||
|
|
||||||
|
### 2.2 [LOW] Two different "no PATH lookup" strategies for external helpers, not one
|
||||||
|
|
||||||
|
`spec.hpp:52-53,75-76` hardcode `/usr/bin/newuidmap`, `/usr/bin/newgidmap`, `/usr/bin/slirp4netns`,
|
||||||
|
`/usr/bin/pasta` as single absolute paths. `resolveBinary()` (`src/util.cpp:397-417`) instead checks a
|
||||||
|
state-dir override then a list of six system directories, and is used for `curl`, `tar`, `sha256sum`
|
||||||
|
and `gpg`. Both avoid a `PATH`-poisoning attack (the actual goal, and both reach it), but the first
|
||||||
|
group breaks if a distribution installs those four tools somewhere other than exactly `/usr/bin` (for
|
||||||
|
example Fedora's `/usr/sbin` for some setuid helpers, or a non-standard prefix), while the second group
|
||||||
|
would still find them. This is not a security gap, it is an inconsistency that will show up as a
|
||||||
|
confusing "helper not found" on a distro the project has not been tested on, where the real fix (run
|
||||||
|
`resolveBinary()` for all five, or document the four as intentionally pinned) is a few lines either way.
|
||||||
|
|
||||||
|
### 2.3 [LOW] Catalog YAML/JSON parsing is hand-rolled string search, not a parser
|
||||||
|
|
||||||
|
`src/image.cpp:160-173` (Alpine's `latest-releases.yaml`) and `src/image.cpp:192-204` (Debian's
|
||||||
|
`index.json`, including a base64-embedded manifest) are read with `line.find(...)` and fixed byte
|
||||||
|
offsets, not a YAML/JSON library. This keeps the dependency count at zero, which fits the project's
|
||||||
|
"fewer moving parts" rule, and it is already tested against the real Alpine and Debian endpoints in
|
||||||
|
`tests/run.sh`. The risk is narrow but real: if either publisher reformats their file (reorders keys,
|
||||||
|
changes indentation, wraps a line), the parser fails closed with a thrown error (good: `docs/RESEARCH.md`
|
||||||
|
already calls this an accepted trade-off for Debian specifically) rather than silently extracting the
|
||||||
|
wrong artifact. Worth a one-line gap entry next to the code, same as the project's own convention asks.
|
||||||
|
|
||||||
|
### 2.4 [LOW] `Sandbox::join`'s ordering is a correctness invariant that is not stated in code
|
||||||
|
|
||||||
|
`src/sandbox.cpp:521-581`. Joining a running container does six `setns()` calls in a plain (not yet
|
||||||
|
seccomp-filtered) forked process, then forks again and *only there* calls `applyLimitsAndEnv`, which
|
||||||
|
installs the seccomp filter that denies `setns` (`src/hardening.cpp:184-186`). This is correct and
|
||||||
|
I could not find a bug in it, but the correctness depends entirely on hardening being applied after
|
||||||
|
the `setns` loop and in the right (inner) fork, a constraint that lives only in the order of statements,
|
||||||
|
not in a comment or an assertion. A future edit that moves `applyLimitsAndEnv` earlier "to simplify"
|
||||||
|
would silently break every second `attach`, and the e2e suite's "second attach joins" case would need
|
||||||
|
to be the thing that catches it rather than a comment telling the next editor not to do that.
|
||||||
|
|
||||||
|
### 2.5 [LOW] No CI, no license file
|
||||||
|
|
||||||
|
`find . -iname "*.yml"` turns up nothing; there is no `.github/workflows`, so the 88-check e2e suite,
|
||||||
|
`make audit` and `make valgrind` only run when someone remembers to run them by hand. There is also no
|
||||||
|
`LICENSE` file, which for a "commercial and scientific" project (the user's own standing rule) means
|
||||||
|
the legal default is "all rights reserved" by anyone who is not the author, worth a deliberate choice
|
||||||
|
rather than an omission.
|
||||||
|
|
||||||
|
### 2.6 [INFO] Everything else I checked was either already documented or held up
|
||||||
|
|
||||||
|
I specifically went looking for bugs in the capability-drop/seccomp-install ordering relative to
|
||||||
|
`execve`, in the overlayfs mount option construction, in the `--size`/`--memory`/`--pids`/`--cpus`
|
||||||
|
range validation (other than 2.1), in the container/image name validators against path traversal, in
|
||||||
|
`Store::remove`'s delete-outside-the-store guard, and in the symlink handling added for the PATH and
|
||||||
|
TOCTOU fixes (`src/util.cpp:329-354`, `src/sandbox.cpp:130-153`). All of it matched what the comments
|
||||||
|
claim, and the specific "mkdirs follows a symlink" class of bug this project itself found via
|
||||||
|
`audit/CVE-2021-30465` is closed the right way (per-component `openat(O_NOFOLLOW)`), not patched over.
|
||||||
|
|
||||||
|
## 3. Grades
|
||||||
|
|
||||||
|
| Category | Grade | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Architecture & code organization | 8.5/10 | One class per responsibility, matches `docs/ARCHITECTURE.md` exactly; every constant in one file; consistent error style throughout 3,558 lines. Loses points only for 2.4's unstated invariant. |
|
||||||
|
| Correctness & input validation | 7/10 | Careful everywhere except one real bug (2.1). CLI parsing, name validation, bounds checks on memory/pids/cpus are all solid; size parsing is the one spot that was not. |
|
||||||
|
| Isolation design (namespaces, overlayfs, mounts) | 9/10 | TOCTOU-safe symlink handling, `O_NOFOLLOW` directory creation, correct remount-flag preservation, correct setns/seccomp ordering. This is the part of the codebase that most clearly got the most attention, and it shows. |
|
||||||
|
| Resource controls (cgroup, size cap) | 8.5/10 | Adaptive scan-duty throttling, freeze/thaw hysteresis, whole-container OOM kill, hard caps on scan size and tree depth. Honestly scoped: documented as advisory without cgroup v2 delegation rather than pretending to enforce. |
|
||||||
|
| Supply chain (image verification) | 8/10 | Real signature verification against pinned fingerprints for two of three catalog sources, sha256 for all three, URL inputs refused without a hash. Loses points for the hand-rolled parsers (2.3) and for Debian having no publisher signature (disclosed, not doos's fault, but still the weakest link). |
|
||||||
|
| Hardening (capabilities, seccomp) | 8/10 | Matches Docker's default capability mask (measured, not assumed), denylist seccomp with the known evasions (x32, clone3, nested userns) explicitly closed. A denylist is inherently weaker than an allowlist by design, which the project says itself in `COMPARISON.md`; grade reflects execution of the chosen design, not the design choice. |
|
||||||
|
| Testing & verification tooling | 8.5/10 | 88 end-to-end checks through the real path (real overlayfs, real downloads, real cgroups), plus `audit/` (18 named CVEs with working reproducers) and `valgrind/` (6 checks, suppressions individually justified) are well above what a project this size usually has. One machine, one architecture, no CI (2.5) is the honest ceiling. |
|
||||||
|
| Documentation | 9/10 | Seven cross-linked documents that state what exists today, with gaps listed next to the code that closes them; generated numbers and charts instead of hand-typed ones; a changelog that actually matches releases. Among the best-documented small C++ projects I have reviewed. |
|
||||||
|
| Build & release automation | 8.5/10 | `scripts/release.sh` bumps, builds, tests, checks docs, commits and tags in one gated command and aborts on any failure; hardened compiler/linker flags. Loses half a point for no CI to run any of it automatically. |
|
||||||
|
| CLI/UX | 7.5/10 | Clear, narrow-terminal-friendly usage text; sensible fixed-vs-changeable option model; good error messages that say what to do next. `--i-understand` as a deliberate confirmation gate for reduced sandboxing is a good pattern. |
|
||||||
|
| Project hygiene | 6/10 | No LICENSE, no CI (2.5); otherwise clean (`.gitignore` is thorough, no stray build artifacts tracked, commit messages are consistent and attributed). |
|
||||||
|
|
||||||
|
**Overall: 8/10.** A small, honest, carefully built tool with one real bug found by testing it rather
|
||||||
|
than reading it, and a short, specific list of things worth fixing. Nothing found here changes the
|
||||||
|
conclusion already in `COMPARISON.md`: fine for your own tools, not a substitute for an audited,
|
||||||
|
multi-tenant-hardened runtime.
|
||||||
|
|
||||||
|
## 4. Recommended order of fixes
|
||||||
|
|
||||||
|
1. Fix the `parseSize` overflow (2.1): cheapest fix, only confirmed bug, trivial to test.
|
||||||
|
2. Add a one-line comment above `applyLimitsAndEnv`'s two call sites in `sandbox.cpp` stating the
|
||||||
|
ordering invariant from 2.4, so the next edit does not have to rediscover it the hard way.
|
||||||
|
3. Decide and document the helper-resolution split (2.2): either move the four pinned paths through
|
||||||
|
`resolveBinary()`, or add one line to `SPEC.md` saying why they are intentionally narrower.
|
||||||
|
4. Add a LICENSE file (2.5); pick whichever the user already uses elsewhere.
|
||||||
|
5. CI is the only item here that is genuinely more than a few lines of work (namespaces, cgroup v2
|
||||||
|
delegation and AppArmor all need to exist on the runner); worth a separate, explicit decision
|
||||||
|
rather than a drive-by addition.
|
||||||
|
|
||||||
|
**Docs:** [README](README.md) · [SPEC](SPEC.md) · [ARCHITECTURE](docs/ARCHITECTURE.md) ·
|
||||||
|
[RESEARCH](docs/RESEARCH.md) · [COMPARISON](COMPARISON.md) · [AGENTS](AGENTS.md) ·
|
||||||
|
[CHANGELOG](CHANGELOG.md) · [audit](audit/README.md) · [valgrind](valgrind/README.md) ·
|
||||||
|
[review.md](review.md) (earlier security-only review)
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
set -u
|
set -u
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md)
|
DOCS=(README.md SPEC.md COMPARISON.md AGENTS.md CHANGELOG.md docs/ARCHITECTURE.md docs/RESEARCH.md docs/CI.md)
|
||||||
bad=0
|
bad=0
|
||||||
fail() { echo "docs: $*"; bad=$((bad+1)); }
|
fail() { echo "docs: $*"; bad=$((bad+1)); }
|
||||||
|
|
||||||
@@ -12,11 +12,11 @@ fail() { echo "docs: $*"; bad=$((bad+1)); }
|
|||||||
while IFS= read -r f; do
|
while IFS= read -r f; do
|
||||||
f=${f#./}
|
f=${f#./}
|
||||||
printf '%s\n' "${DOCS[@]}" | grep -qxF "$f" || fail "$f is not in the DOCS list of scripts/check-docs.sh"
|
printf '%s\n' "${DOCS[@]}" | grep -qxF "$f" || fail "$f is not in the DOCS list of scripts/check-docs.sh"
|
||||||
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md')
|
done < <(find . -name '*.md' -not -path './build/*' -not -path './.git/*' -not -path './prompt.md' -not -path './audit/*' -not -path './review.md' -not -path './review2.md' -not -path './valgrind/*')
|
||||||
# prompt.md: the original task statement, not documentation.
|
# prompt.md: the original task statement, not documentation.
|
||||||
# audit/*: a separate, self-contained doc set (one README per CVE check); audit/README.md is
|
# audit/*, valgrind/*: separate, self-contained doc sets (one README per CVE check, one for the
|
||||||
# linked from the main docs but is not forced into their full cross-link mesh.
|
# valgrind setup); linked from the main docs but not forced into their full cross-link mesh.
|
||||||
# review.md: a standalone external security review, not part of the maintained doc set.
|
# review.md, review2.md: standalone one-off reviews, not part of the maintained doc set.
|
||||||
|
|
||||||
for f in "${DOCS[@]}"; do
|
for f in "${DOCS[@]}"; do
|
||||||
[ -f "$f" ] || { fail "$f is missing"; continue; }
|
[ -f "$f" ] || { fail "$f is missing"; continue; }
|
||||||
|
|||||||
+1
-1
@@ -32,6 +32,6 @@ tests/run.sh
|
|||||||
git add -A
|
git add -A
|
||||||
git commit -m "$subject
|
git commit -m "$subject
|
||||||
|
|
||||||
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||||
git tag -a "v$new" -m "doos $new"
|
git tag -a "v$new" -m "doos $new"
|
||||||
echo "released v$new"
|
echo "released v$new"
|
||||||
|
|||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs a fixed, named set of doos invocations under valgrind's memcheck, using
|
||||||
|
# valgrind/doos.supp to silence the known, explained allocation sites (see that file's
|
||||||
|
# comments), and fails only on a real, unsuppressed memory error or leak. Does not rebuild;
|
||||||
|
# run `make build` (or just `make valgrind`, which depends on it) first.
|
||||||
|
#
|
||||||
|
# Usage: scripts/valgrind.sh [path/to/doos]
|
||||||
|
set -u
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
DOOS="${1:-build/doos}"
|
||||||
|
SUPP="$(pwd)/valgrind/doos.supp"
|
||||||
|
REPORTS="build/valgrind-reports"
|
||||||
|
TIMEOUT=60
|
||||||
|
|
||||||
|
command -v valgrind >/dev/null || { echo "valgrind: not installed"; exit 1; }
|
||||||
|
[ -x "$DOOS" ] || { echo "valgrind: no doos binary at '$DOOS' (run 'make build' first)"; exit 1; }
|
||||||
|
[ -f "$SUPP" ] || { echo "valgrind: suppressions file missing: $SUPP"; exit 1; }
|
||||||
|
|
||||||
|
rm -rf "$REPORTS"
|
||||||
|
mkdir -p "$REPORTS"
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
export DOOS_HOME="$TMP/home"
|
||||||
|
|
||||||
|
fails=0
|
||||||
|
checked=0
|
||||||
|
|
||||||
|
run() { # run NAME -- DOOS_ARGS...
|
||||||
|
local name="$1"; shift
|
||||||
|
shift # drop the --
|
||||||
|
checked=$((checked + 1))
|
||||||
|
local log="$REPORTS/$name.log"
|
||||||
|
timeout "$TIMEOUT" valgrind \
|
||||||
|
--leak-check=full --show-leak-kinds=all --track-origins=yes \
|
||||||
|
--suppressions="$SUPP" --error-exitcode=99 \
|
||||||
|
--log-file="$log" \
|
||||||
|
"$DOOS" "$@" >/dev/null 2>&1
|
||||||
|
local rc=$?
|
||||||
|
if [ "$rc" = 99 ]; then
|
||||||
|
echo "FAIL $name: valgrind reported an unsuppressed error or leak, see $log"
|
||||||
|
fails=$((fails + 1))
|
||||||
|
elif [ "$rc" = 124 ]; then
|
||||||
|
echo "FAIL $name: timed out after ${TIMEOUT}s under valgrind, see $log"
|
||||||
|
fails=$((fails + 1))
|
||||||
|
else
|
||||||
|
echo "ok $name (doos exit=$rc; valgrind found nothing to report), see $log"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fixed, named set: the four CLI commands SPEC.md documents, plus a second attach to exercise
|
||||||
|
# the join-a-running-container path. No image download here (network + extraction add their
|
||||||
|
# own long-running C library code, better covered once by a dedicated image-mode run; see
|
||||||
|
# valgrind/README.md for how to add one).
|
||||||
|
run list -- list
|
||||||
|
run get_or_create -- get_or_create --net none vgcheck
|
||||||
|
run attach_first -- attach --net none vgcheck -- true
|
||||||
|
run attach_join -- attach --net none vgcheck -- true
|
||||||
|
run image_list -- image list
|
||||||
|
run delete -- delete --yes vgcheck
|
||||||
|
|
||||||
|
echo "valgrind: $checked checked, $fails failed, reports in $REPORTS/"
|
||||||
|
[ "$fails" -eq 0 ]
|
||||||
+165
-4
@@ -7,6 +7,7 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include "container.hpp"
|
#include "container.hpp"
|
||||||
|
#include "davmount.hpp"
|
||||||
#include "sandbox.hpp"
|
#include "sandbox.hpp"
|
||||||
#include "spec.hpp"
|
#include "spec.hpp"
|
||||||
#include "util.hpp"
|
#include "util.hpp"
|
||||||
@@ -18,11 +19,92 @@ namespace {
|
|||||||
std::uint32_t parseCount(const std::string& text);
|
std::uint32_t parseCount(const std::string& text);
|
||||||
std::uint32_t parseCpus(const std::string& text);
|
std::uint32_t parseCpus(const std::string& text);
|
||||||
|
|
||||||
|
// "SRC:DST" or "SRC:DST:ro"/"SRC:DST:rw" (paths themselves may not contain ':').
|
||||||
|
MountSpec parseMountFlag(const std::string& text) {
|
||||||
|
std::vector<std::string> parts;
|
||||||
|
std::string cur;
|
||||||
|
for (char ch : text) {
|
||||||
|
if (ch == ':') { parts.push_back(cur); cur.clear(); }
|
||||||
|
else cur += ch;
|
||||||
|
}
|
||||||
|
parts.push_back(cur);
|
||||||
|
if (parts.size() < 2 || parts.size() > 3) {
|
||||||
|
throw std::runtime_error("--mount needs SRC:DST or SRC:DST:ro (got '" + text + "')");
|
||||||
|
}
|
||||||
|
MountSpec m;
|
||||||
|
m.src = parts[0];
|
||||||
|
m.dst = parts[1];
|
||||||
|
if (parts.size() == 3) {
|
||||||
|
if (parts[2] != "ro" && parts[2] != "rw") {
|
||||||
|
throw std::runtime_error("--mount's third field must be 'ro' or 'rw' (got '" + parts[2] + "')");
|
||||||
|
}
|
||||||
|
m.readOnly = parts[2] == "ro";
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasDavScheme(const std::string& s) {
|
||||||
|
for (const char* p : {"dav://", "davs://", "http://", "https://"}) {
|
||||||
|
if (s.rfind(p, 0) == 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "DST" or "DST:ro"/"DST:rw" (the URL itself is a separate token, so this never sees a scheme's own ':').
|
||||||
|
void applyDstSuffix(MountSpec& m, const std::string& distSpec) {
|
||||||
|
auto c = distSpec.find(':');
|
||||||
|
if (c == std::string::npos) { m.dst = distSpec; return; }
|
||||||
|
m.dst = distSpec.substr(0, c);
|
||||||
|
std::string suf = distSpec.substr(c + 1);
|
||||||
|
if (suf != "ro" && suf != "rw") {
|
||||||
|
throw std::runtime_error("mount destination suffix must be ':ro' or ':rw' (got '" + distSpec + "')");
|
||||||
|
}
|
||||||
|
m.readOnly = suf == "ro";
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL contains ':' (scheme, maybe a port), so it cannot share a single colon-delimited token with
|
||||||
|
// DST the way the classic bind-mount form does: --mount/--davmount takes the URL and DST as two
|
||||||
|
// separate argv tokens instead. Credentials embedded as user[:pass]@host are pulled out of the URL
|
||||||
|
// (a password containing '@' will not round-trip through this; use --dav-user/--dav-pass instead).
|
||||||
|
MountSpec parseDavMountFlag(const std::string& url, const std::string& distSpec) {
|
||||||
|
MountSpec m;
|
||||||
|
m.kind = MountKind::WebDav;
|
||||||
|
m.davVendor = "other";
|
||||||
|
std::string rest;
|
||||||
|
if (url.rfind("dav://", 0) == 0) rest = "http://" + url.substr(6);
|
||||||
|
else if (url.rfind("davs://", 0) == 0) rest = "https://" + url.substr(7);
|
||||||
|
else rest = url; // http:// or https://, used as given
|
||||||
|
|
||||||
|
std::size_t schemeEnd = rest.find("://");
|
||||||
|
schemeEnd = schemeEnd == std::string::npos ? 0 : schemeEnd + 3;
|
||||||
|
std::size_t pathStart = rest.find('/', schemeEnd);
|
||||||
|
std::string authority = rest.substr(schemeEnd, pathStart == std::string::npos ? std::string::npos : pathStart - schemeEnd);
|
||||||
|
std::size_t at = authority.rfind('@');
|
||||||
|
if (at != std::string::npos) {
|
||||||
|
std::string userinfo = authority.substr(0, at), hostport = authority.substr(at + 1);
|
||||||
|
std::size_t colon = userinfo.find(':');
|
||||||
|
if (colon == std::string::npos) m.davUser = userinfo;
|
||||||
|
else { m.davUser = userinfo.substr(0, colon); m.davPass = userinfo.substr(colon + 1); }
|
||||||
|
rest = rest.substr(0, schemeEnd) + hostport + (pathStart == std::string::npos ? "" : rest.substr(pathStart));
|
||||||
|
}
|
||||||
|
m.src = rest;
|
||||||
|
applyDstSuffix(m, distSpec);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
MountSpec& lastDavMount(Options& opts, const char* flag) {
|
||||||
|
if (!opts.mounts || opts.mounts->empty() || opts.mounts->back().kind != MountKind::WebDav) {
|
||||||
|
throw std::runtime_error(std::string(flag) + " must follow a --mount dav://... (or --davmount) entry");
|
||||||
|
}
|
||||||
|
return opts.mounts->back();
|
||||||
|
}
|
||||||
|
|
||||||
// Leading --options, then the first positional, then everything else untouched.
|
// Leading --options, then the first positional, then everything else untouched.
|
||||||
struct Parsed {
|
struct Parsed {
|
||||||
Options opts;
|
Options opts;
|
||||||
std::optional<std::string> sha256;
|
std::optional<std::string> sha256;
|
||||||
bool yes = false;
|
bool yes = false;
|
||||||
|
bool iUnderstand = false; // --i-understand, required for relaxed / --net host
|
||||||
std::vector<std::string> positional; // up to maxPositional, the rest lands in tail
|
std::vector<std::string> positional; // up to maxPositional, the rest lands in tail
|
||||||
std::vector<std::string> tail;
|
std::vector<std::string> tail;
|
||||||
};
|
};
|
||||||
@@ -58,7 +140,29 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
|||||||
if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed");
|
if (v != "default" && v != "relaxed") throw std::runtime_error("--hardening takes default or relaxed");
|
||||||
p.opts.relaxed = (v == "relaxed");
|
p.opts.relaxed = (v == "relaxed");
|
||||||
}
|
}
|
||||||
|
else if (allow.container && (is(a, "--mount") || is(a, "--davmount"))) {
|
||||||
|
bool explicitDav = is(a, "--davmount");
|
||||||
|
std::string flagName = explicitDav ? "--davmount" : "--mount";
|
||||||
|
std::string first = value(i, flagName);
|
||||||
|
if (!p.opts.mounts) p.opts.mounts = std::vector<MountSpec>{};
|
||||||
|
if (p.opts.mounts->size() >= spec::kMaxMounts) {
|
||||||
|
throw std::runtime_error("too many --mount/--davmount flags (max " + std::to_string(spec::kMaxMounts) + ")");
|
||||||
|
}
|
||||||
|
if (hasDavScheme(first)) {
|
||||||
|
if (i + 1 >= rest.size()) throw std::runtime_error(flagName + " needs a destination after the URL");
|
||||||
|
p.opts.mounts->push_back(parseDavMountFlag(first, rest[++i]));
|
||||||
|
} else if (explicitDav) {
|
||||||
|
throw std::runtime_error("--davmount needs a dav://, davs://, http:// or https:// URL (got '" + first + "')");
|
||||||
|
} else {
|
||||||
|
p.opts.mounts->push_back(parseMountFlag(first));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (allow.container && is(a, "--dav-user")) lastDavMount(p.opts, "--dav-user").davUser = value(i, "--dav-user");
|
||||||
|
else if (allow.container && is(a, "--dav-pass")) lastDavMount(p.opts, "--dav-pass").davPass = value(i, "--dav-pass");
|
||||||
|
else if (allow.container && is(a, "--dav-vendor")) lastDavMount(p.opts, "--dav-vendor").davVendor = value(i, "--dav-vendor");
|
||||||
|
else if (allow.container && a == "--dav-insecure") lastDavMount(p.opts, "--dav-insecure").davInsecure = true;
|
||||||
else if (allow.yes && a == "--yes") p.yes = true;
|
else if (allow.yes && a == "--yes") p.yes = true;
|
||||||
|
else if (a == "--i-understand") p.iUnderstand = true;
|
||||||
else if (allow.sha256 && is(a, "--sha256")) p.sha256 = value(i, "--sha256");
|
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);
|
else if (a.size() > 1 && a[0] == '-') throw std::runtime_error("unknown option " + a);
|
||||||
else if (p.positional.size() < wantPositional) p.positional.push_back(a);
|
else if (p.positional.size() < wantPositional) p.positional.push_back(a);
|
||||||
@@ -70,6 +174,21 @@ Parsed parse(const std::vector<std::string>& rest, Allowed allow, std::size_t wa
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void hardenNotice(const Container& c, const Parsed& p) {
|
||||||
|
bool relaxed = p.opts.relaxed.value_or(c.relaxed());
|
||||||
|
bool hostNet = (p.opts.net.has_value() ? *p.opts.net : c.net()) == NetMode::Host;
|
||||||
|
if ((relaxed || hostNet) && !p.iUnderstand) {
|
||||||
|
throw std::runtime_error("--hardening relaxed and --net host drop capability drop, seccomp filtering and/or "
|
||||||
|
"network isolation; pass --i-understand to confirm you accept the reduced sandboxing");
|
||||||
|
}
|
||||||
|
if (relaxed || hostNet) {
|
||||||
|
std::fprintf(stderr, "doos: warning: reduced sandboxing (%s%s%s)\n",
|
||||||
|
relaxed ? "no capability drop, no seccomp, full /etc" : "",
|
||||||
|
(relaxed && hostNet) ? "; " : "",
|
||||||
|
hostNet ? "shares the host network namespace" : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::uint32_t parseCount(const std::string& text) {
|
std::uint32_t parseCount(const std::string& text) {
|
||||||
if (text.empty() || text.size() > 9 || text.find_first_not_of("0123456789") != std::string::npos) {
|
if (text.empty() || text.size() > 9 || text.find_first_not_of("0123456789") != std::string::npos) {
|
||||||
throw std::runtime_error("invalid number '" + text + "'");
|
throw std::runtime_error("invalid number '" + text + "'");
|
||||||
@@ -112,21 +231,34 @@ void Cli::usage(std::FILE* out) {
|
|||||||
" doos image list\n"
|
" doos image list\n"
|
||||||
" doos image add [--sha256 HEX] SOURCE [NAME]\n"
|
" doos image add [--sha256 HEX] SOURCE [NAME]\n"
|
||||||
" doos image delete [--yes] NAME\n"
|
" doos image delete [--yes] NAME\n"
|
||||||
|
" doos davmount list\n"
|
||||||
|
" doos davmount umount [--yes] ID\n"
|
||||||
" doos apparmor print the AppArmor profile this binary needs on Ubuntu 24.04+\n"
|
" doos apparmor print the AppArmor profile this binary needs on Ubuntu 24.04+\n"
|
||||||
"\n"
|
"\n"
|
||||||
"options:\n"
|
"options:\n"
|
||||||
" --image NAME start from an image instead of the host system (fetched on demand) [fixed]\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"
|
" --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"
|
" --ids single|subuid id mapping, default: subuid when available [fixed]\n"
|
||||||
" --hardening default|relaxed relaxed = no capability drop, no seccomp, full /etc [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"
|
" --i-understand confirm you accept reduced sandboxing when using relaxed or --net host\n"
|
||||||
|
" --size SIZE writable-layer cap, 64m..1t, default 4g\n"
|
||||||
" --memory SIZE memory cap, 64m..1t, default 8g\n"
|
" --memory SIZE memory cap, 64m..1t, default 8g\n"
|
||||||
" --pids N process cap, 16..4194304, default 4096\n"
|
" --pids N process cap, 16..4194304, default 4096\n"
|
||||||
" --cpus N cpu cap, 0.1..1024, default 0 (all cores)\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"
|
" --mount SRC:DST[:ro] bind-mount host path SRC at DST inside the container, repeatable (max 8)\n"
|
||||||
|
" --mount URL DST[:ro] URL starting dav://, davs://, http:// or https:// auto-mounts WebDAV via a\n"
|
||||||
|
" managed rclone (downloaded and verified once); same as --davmount\n"
|
||||||
|
" --davmount URL DST[:ro] explicit WebDAV mount: URL DST[:ro], credentials as user[:pass]@host or below\n"
|
||||||
|
" --dav-user USER WebDAV username for the --mount/--davmount entry just given\n"
|
||||||
|
" --dav-pass PASS WebDAV password for it (use this instead of embedding '@'/':' in the URL)\n"
|
||||||
|
" --dav-vendor NAME other|nextcloud|owncloud|sharepoint|sharepoint-ntlm, default other\n"
|
||||||
|
" --dav-insecure skip TLS certificate verification for that WebDAV target\n"
|
||||||
|
" size, memory, pids, cpus and mount can be changed on any later call; they apply at the next start.\n"
|
||||||
|
" mount replaces the whole mount list; a bind SRC must exist and be a directory when the container starts.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"SOURCE for image add: alpine, ubuntu, ubuntu-24.04, debian, debian-bookworm, 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"
|
"attach creates NAME when missing. delete only reports unless --yes is given.\n"
|
||||||
|
"davmount umount stops storage for any container still using that target; see 'doos davmount list' for its ID.\n"
|
||||||
"State lives in $DOOS_HOME or ~/.local/share/doos.\n",
|
"State lives in $DOOS_HOME or ~/.local/share/doos.\n",
|
||||||
out);
|
out);
|
||||||
}
|
}
|
||||||
@@ -145,6 +277,7 @@ int Cli::run() {
|
|||||||
if (cmd == "attach") return cmdAttach(rest);
|
if (cmd == "attach") return cmdAttach(rest);
|
||||||
if (cmd == "delete") return cmdDelete(rest);
|
if (cmd == "delete") return cmdDelete(rest);
|
||||||
if (cmd == "image") return cmdImage(rest);
|
if (cmd == "image") return cmdImage(rest);
|
||||||
|
if (cmd == "davmount") return cmdDavmount(rest);
|
||||||
if (cmd == "apparmor") return cmdApparmor();
|
if (cmd == "apparmor") return cmdApparmor();
|
||||||
std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str());
|
std::fprintf(stderr, "doos: unknown command '%s'\n\n", args_[0].c_str());
|
||||||
usage(stderr);
|
usage(stderr);
|
||||||
@@ -169,6 +302,7 @@ int Cli::cmdGetOrCreate(std::vector<std::string> rest) {
|
|||||||
if (!p.tail.empty()) throw std::runtime_error("get_or_create takes only a name");
|
if (!p.tail.empty()) throw std::runtime_error("get_or_create takes only a name");
|
||||||
Store store = Store::fromEnvironment();
|
Store store = Store::fromEnvironment();
|
||||||
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
||||||
|
hardenNotice(c, p);
|
||||||
std::printf("%s %s\n", created ? "created" : "exists", c.name().c_str());
|
std::printf("%s %s\n", created ? "created" : "exists", c.name().c_str());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -178,6 +312,7 @@ int Cli::cmdAttach(std::vector<std::string> rest) {
|
|||||||
Store store = Store::fromEnvironment();
|
Store store = Store::fromEnvironment();
|
||||||
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
auto [c, created] = store.getOrCreate(p.positional[0], p.opts);
|
||||||
(void)created;
|
(void)created;
|
||||||
|
hardenNotice(c, p);
|
||||||
return Sandbox(std::move(c), std::move(p.tail)).run();
|
return Sandbox(std::move(c), std::move(p.tail)).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,4 +404,30 @@ int Cli::cmdImageDelete(std::vector<std::string> rest) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int Cli::cmdDavmount(std::vector<std::string> rest) {
|
||||||
|
if (rest.empty()) throw std::runtime_error("davmount needs a subcommand: list, umount");
|
||||||
|
std::string sub = rest[0];
|
||||||
|
std::vector<std::string> tail(rest.begin() + 1, rest.end());
|
||||||
|
if (sub == "list") return cmdDavmountList();
|
||||||
|
if (sub == "umount") return cmdDavmountUmount(tail);
|
||||||
|
throw std::runtime_error("unknown davmount subcommand '" + sub + "' (list, umount)");
|
||||||
|
}
|
||||||
|
|
||||||
|
int Cli::cmdDavmountList() {
|
||||||
|
auto all = DavMount::list();
|
||||||
|
if (all.empty()) { std::puts("no davmounts"); return 0; }
|
||||||
|
std::printf("%-*s %-7s %-10s %s\n", 16, "ID", "STATE", "VENDOR", "URL");
|
||||||
|
for (const auto& s : all) {
|
||||||
|
std::printf("%-*s %-7s %-10s %s\n", 16, s.id.c_str(), s.mounted ? "mounted" : "stale", s.vendor.c_str(), s.url.c_str());
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Cli::cmdDavmountUmount(std::vector<std::string> rest) {
|
||||||
|
Parsed p = parse(rest, {false, true, false}, 1);
|
||||||
|
if (!p.tail.empty()) throw std::runtime_error("davmount umount takes only an ID");
|
||||||
|
std::puts(DavMount::umount(p.positional[0], p.yes).c_str());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace doos
|
} // namespace doos
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ private:
|
|||||||
int cmdImageList();
|
int cmdImageList();
|
||||||
int cmdImageAdd(std::vector<std::string> rest);
|
int cmdImageAdd(std::vector<std::string> rest);
|
||||||
int cmdImageDelete(std::vector<std::string> rest);
|
int cmdImageDelete(std::vector<std::string> rest);
|
||||||
|
int cmdDavmount(std::vector<std::string> rest);
|
||||||
|
int cmdDavmountList();
|
||||||
|
int cmdDavmountUmount(std::vector<std::string> rest);
|
||||||
static void usage(std::FILE* out);
|
static void usage(std::FILE* out);
|
||||||
|
|
||||||
std::vector<std::string> args_;
|
std::vector<std::string> args_;
|
||||||
|
|||||||
+118
-3
@@ -26,6 +26,100 @@ std::string slug(std::string_view hostDir) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool noNewline(const std::string& s) { return s.find('\n') == std::string::npos; }
|
||||||
|
|
||||||
|
void validateMount(const MountSpec& m) {
|
||||||
|
if (m.dst.empty() || m.dst[0] != '/' || m.dst == "/" || !noNewline(m.dst) || m.dst.find(':') != std::string::npos) {
|
||||||
|
throw std::runtime_error("--mount destination must be an absolute path, not '/', without ':' or a newline: '" + m.dst + "'");
|
||||||
|
}
|
||||||
|
if (m.kind == MountKind::Bind) {
|
||||||
|
if (m.src.empty() || m.src[0] != '/' || !noNewline(m.src) || m.src.find(':') != std::string::npos) {
|
||||||
|
throw std::runtime_error("--mount source must be an absolute path without ':' or a newline: '" + m.src + "'");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bool https = m.src.rfind("https://", 0) == 0, http = m.src.rfind("http://", 0) == 0;
|
||||||
|
if (m.src.empty() || (!https && !http) || !noNewline(m.src)) {
|
||||||
|
throw std::runtime_error("davmount source must be an http:// or https:// URL: '" + m.src + "'");
|
||||||
|
}
|
||||||
|
if (!noNewline(m.davUser) || !noNewline(m.davPass) || !noNewline(m.davVendor)) {
|
||||||
|
throw std::runtime_error("davmount user/pass/vendor must not contain a newline");
|
||||||
|
}
|
||||||
|
bool knownVendor = false;
|
||||||
|
for (auto v : spec::kDavVendors) knownVendor = knownVendor || v == m.davVendor;
|
||||||
|
if (!knownVendor) {
|
||||||
|
std::string known;
|
||||||
|
for (auto v : spec::kDavVendors) known += (known.empty() ? "" : ", ") + std::string(v);
|
||||||
|
throw std::runtime_error("unknown --dav-vendor '" + m.davVendor + "' (known: " + known + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current format: one key per field, indexed, so credentials may contain any character but a
|
||||||
|
// newline (unlike the legacy single-line format below). `mounts.count=N` then, per i: `.kind`,
|
||||||
|
// `.src`, `.dst`, `.ro`, and for webdav also `.user`, `.pass`, `.vendor`, `.insecure`.
|
||||||
|
std::string serializeMounts(const std::vector<MountSpec>& mounts) {
|
||||||
|
std::string out = "mounts.count=" + std::to_string(mounts.size()) + "\n";
|
||||||
|
for (std::size_t i = 0; i < mounts.size(); ++i) {
|
||||||
|
const MountSpec& m = mounts[i];
|
||||||
|
std::string p = "mounts." + std::to_string(i) + ".";
|
||||||
|
out += p + "kind=" + (m.kind == MountKind::WebDav ? "webdav" : "bind") + "\n";
|
||||||
|
out += p + "src=" + m.src + "\n";
|
||||||
|
out += p + "dst=" + m.dst + "\n";
|
||||||
|
out += p + "ro=" + (m.readOnly ? "1" : "0") + "\n";
|
||||||
|
if (m.kind == MountKind::WebDav) {
|
||||||
|
out += p + "user=" + m.davUser + "\n";
|
||||||
|
out += p + "pass=" + m.davPass + "\n";
|
||||||
|
out += p + "vendor=" + m.davVendor + "\n";
|
||||||
|
out += p + "insecure=" + (m.davInsecure ? "1" : "0") + "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy format (1.5.0): a single "mounts=" line, entries separated by ';', fields by ':'
|
||||||
|
// ("src:dst:ro"), bind mounts only. Still read so a container created under that release loads.
|
||||||
|
std::vector<MountSpec> parseLegacyMounts(const std::string& text) {
|
||||||
|
std::vector<MountSpec> out;
|
||||||
|
std::istringstream in(text);
|
||||||
|
for (std::string entry; std::getline(in, entry, ';');) {
|
||||||
|
if (entry.empty()) continue;
|
||||||
|
std::size_t c1 = entry.find(':');
|
||||||
|
std::size_t c2 = entry.rfind(':');
|
||||||
|
if (c1 == std::string::npos || c2 == c1) throw std::runtime_error("corrupt mount entry in meta: '" + entry + "'");
|
||||||
|
MountSpec m;
|
||||||
|
m.src = entry.substr(0, c1);
|
||||||
|
m.dst = entry.substr(c1 + 1, c2 - c1 - 1);
|
||||||
|
m.readOnly = entry.substr(c2 + 1) == "ro";
|
||||||
|
out.push_back(std::move(m));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<MountSpec> parseMounts(const std::map<std::string, std::string>& kv) {
|
||||||
|
if (!kv.count("mounts.count")) return kv.count("mounts") ? parseLegacyMounts(kv.at("mounts")) : std::vector<MountSpec>{};
|
||||||
|
std::size_t n = std::stoul(kv.at("mounts.count"));
|
||||||
|
if (n > spec::kMaxMounts) throw std::runtime_error("corrupt meta: too many mounts");
|
||||||
|
std::vector<MountSpec> out;
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
std::string p = "mounts." + std::to_string(i) + ".";
|
||||||
|
auto get = [&](const char* field, const std::string& def = "") {
|
||||||
|
auto it = kv.find(p + field);
|
||||||
|
return it == kv.end() ? def : it->second;
|
||||||
|
};
|
||||||
|
MountSpec m;
|
||||||
|
m.kind = get("kind") == "webdav" ? MountKind::WebDav : MountKind::Bind;
|
||||||
|
m.src = get("src");
|
||||||
|
m.dst = get("dst");
|
||||||
|
m.readOnly = get("ro") == "1";
|
||||||
|
m.davUser = get("user");
|
||||||
|
m.davPass = get("pass");
|
||||||
|
m.davVendor = get("vendor", "other");
|
||||||
|
m.davInsecure = get("insecure") == "1";
|
||||||
|
out.push_back(std::move(m));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
std::map<std::string, std::string> parseKv(const std::string& text) {
|
std::map<std::string, std::string> parseKv(const std::string& text) {
|
||||||
std::map<std::string, std::string> kv;
|
std::map<std::string, std::string> kv;
|
||||||
std::istringstream in(text);
|
std::istringstream in(text);
|
||||||
@@ -225,10 +319,12 @@ std::uint64_t Container::usedBytes(std::size_t& skipped) const {
|
|||||||
wr.reset();
|
wr.reset();
|
||||||
if (rc != 0) throw std::runtime_error("size scan failed");
|
if (rc != 0) throw std::runtime_error("size scan failed");
|
||||||
char buf[64] = {0};
|
char buf[64] = {0};
|
||||||
if (::read(rd.get(), buf, sizeof buf - 1) <= 0) throw std::runtime_error("size scan returned nothing");
|
ssize_t rd_n = ::read(rd.get(), buf, sizeof buf - 1);
|
||||||
|
if (rd_n <= 0) throw std::runtime_error("size scan returned nothing");
|
||||||
|
buf[rd_n] = '\0';
|
||||||
unsigned long long t = 0;
|
unsigned long long t = 0;
|
||||||
std::size_t sk = 0;
|
std::size_t sk = 0;
|
||||||
std::sscanf(buf, "%llu %zu", &t, &sk);
|
if (std::sscanf(buf, "%llu %zu", &t, &sk) != 2) throw std::runtime_error("size scan returned malformed data");
|
||||||
skipped += sk;
|
skipped += sk;
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
@@ -238,7 +334,8 @@ void Container::writeMeta() const {
|
|||||||
"\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) +
|
"\nversion=" DOOS_VERSION "\nimage=" + image_ + "\nids=" + IdMap::name(ids_) +
|
||||||
"\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) +
|
"\nsize=" + std::to_string(size_) + "\nmemory=" + std::to_string(memory_) +
|
||||||
"\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) +
|
"\npids=" + std::to_string(pids_) + "\ncpus=" + std::to_string(milliCpus_) +
|
||||||
"\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n");
|
"\nhardening=" + (relaxed_ ? "relaxed" : "default") + "\n" +
|
||||||
|
serializeMounts(mounts_));
|
||||||
}
|
}
|
||||||
|
|
||||||
void Container::setSizeCap(std::uint64_t bytes) {
|
void Container::setSizeCap(std::uint64_t bytes) {
|
||||||
@@ -250,6 +347,15 @@ void Container::setSizeCap(std::uint64_t bytes) {
|
|||||||
writeMeta();
|
writeMeta();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Container::setMounts(const std::vector<MountSpec>& mounts) {
|
||||||
|
if (mounts.size() > spec::kMaxMounts) {
|
||||||
|
throw std::runtime_error("too many mounts (max " + std::to_string(spec::kMaxMounts) + ")");
|
||||||
|
}
|
||||||
|
for (const auto& m : mounts) validateMount(m);
|
||||||
|
mounts_ = mounts;
|
||||||
|
writeMeta();
|
||||||
|
}
|
||||||
|
|
||||||
void Container::setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids,
|
void Container::setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids,
|
||||||
std::optional<std::uint32_t> milliCpus) {
|
std::optional<std::uint32_t> milliCpus) {
|
||||||
if (memory) {
|
if (memory) {
|
||||||
@@ -285,6 +391,13 @@ void Container::create(const NetMode& net, const std::string& image, IdMode ids,
|
|||||||
pids_ = spec::kDefaultPids;
|
pids_ = spec::kDefaultPids;
|
||||||
milliCpus_ = 0;
|
milliCpus_ = 0;
|
||||||
if (o.memory || o.pids || o.milliCpus) setLimits(o.memory, o.pids, o.milliCpus);
|
if (o.memory || o.pids || o.milliCpus) setLimits(o.memory, o.pids, o.milliCpus);
|
||||||
|
if (o.mounts) {
|
||||||
|
if (o.mounts->size() > spec::kMaxMounts) {
|
||||||
|
throw std::runtime_error("too many mounts (max " + std::to_string(spec::kMaxMounts) + ")");
|
||||||
|
}
|
||||||
|
for (const auto& m : *o.mounts) validateMount(m);
|
||||||
|
mounts_ = *o.mounts;
|
||||||
|
}
|
||||||
created_ = std::time(nullptr);
|
created_ = std::time(nullptr);
|
||||||
fsutil::mkdirs(dir_);
|
fsutil::mkdirs(dir_);
|
||||||
prepareLayout();
|
prepareLayout();
|
||||||
@@ -307,6 +420,7 @@ void Container::readMeta() {
|
|||||||
pids_ = kv.count("pids") ? static_cast<std::uint32_t>(std::stoul(kv["pids"])) : spec::kDefaultPids;
|
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;
|
milliCpus_ = kv.count("cpus") ? static_cast<std::uint32_t>(std::stoul(kv["cpus"])) : 0;
|
||||||
relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed";
|
relaxed_ = kv.count("hardening") && kv["hardening"] == "relaxed";
|
||||||
|
mounts_ = parseMounts(kv);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<pid_t> Container::runningPid() const {
|
std::optional<pid_t> Container::runningPid() const {
|
||||||
@@ -397,6 +511,7 @@ std::pair<Container, bool> Store::getOrCreate(const std::string& name, const Opt
|
|||||||
if (opts.relaxed) fixed("hardening", c.relaxed() ? "relaxed" : "default", *opts.relaxed ? "relaxed" : "default");
|
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.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 (opts.memory || opts.pids || opts.milliCpus) c.setLimits(opts.memory, opts.pids, opts.milliCpus);
|
||||||
|
if (opts.mounts) c.setMounts(*opts.mounts);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fs::exists(dir / "meta", ec)) {
|
if (fs::exists(dir / "meta", ec)) {
|
||||||
|
|||||||
@@ -15,6 +15,26 @@
|
|||||||
|
|
||||||
namespace doos {
|
namespace doos {
|
||||||
|
|
||||||
|
// One --mount entry: bind-mounted at `dst` inside the container, in addition to the usual
|
||||||
|
// overlay/image root. Not subject to the --size cap: it is host storage, not the container's own
|
||||||
|
// writable layer.
|
||||||
|
// Bind: --mount SRC:DST[:ro] SRC is an existing host directory.
|
||||||
|
// WebDav: --mount URL DST[:ro] --davmount URL DST[:ro] (URL: dav://, davs://, http://
|
||||||
|
// or https://). `src` holds the http(s) URL rclone mounts on demand (see davmount.hpp);
|
||||||
|
// doos never speaks WebDAV itself, it only drives a managed rclone binary and then
|
||||||
|
// bind-mounts the result exactly like a Bind entry.
|
||||||
|
enum class MountKind { Bind, WebDav };
|
||||||
|
struct MountSpec {
|
||||||
|
MountKind kind = MountKind::Bind;
|
||||||
|
std::string src; // Bind: host path. WebDav: the http(s) URL (credentials never embedded here).
|
||||||
|
std::string dst; // absolute path inside the container
|
||||||
|
bool readOnly = false;
|
||||||
|
std::string davUser; // WebDav only, optional
|
||||||
|
std::string davPass; // WebDav only, optional, plaintext in meta (see SPEC.md); obscured just before rclone runs
|
||||||
|
std::string davVendor; // WebDav only, one of spec::kDavVendors, default "other"
|
||||||
|
bool davInsecure = false; // WebDav only: skip TLS certificate verification
|
||||||
|
};
|
||||||
|
|
||||||
// What the user asked for. Unset fields mean "default on create, no constraint on existing".
|
// What the user asked for. Unset fields mean "default on create, no constraint on existing".
|
||||||
struct Options {
|
struct Options {
|
||||||
std::optional<NetMode> net;
|
std::optional<NetMode> net;
|
||||||
@@ -25,6 +45,7 @@ struct Options {
|
|||||||
std::optional<std::uint32_t> pids;
|
std::optional<std::uint32_t> pids;
|
||||||
std::optional<std::uint32_t> milliCpus; // 0 = all cores
|
std::optional<std::uint32_t> milliCpus; // 0 = all cores
|
||||||
std::optional<bool> relaxed; // --hardening relaxed
|
std::optional<bool> relaxed; // --hardening relaxed
|
||||||
|
std::optional<std::vector<MountSpec>> mounts; // --mount, replaces the whole list when given
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -56,8 +77,15 @@ public:
|
|||||||
std::uint32_t pidsCap() const { return pids_; }
|
std::uint32_t pidsCap() const { return pids_; }
|
||||||
std::uint32_t milliCpus() const { return milliCpus_; }
|
std::uint32_t milliCpus() const { return milliCpus_; }
|
||||||
bool relaxed() const { return relaxed_; }
|
bool relaxed() const { return relaxed_; }
|
||||||
|
const std::vector<MountSpec>& mounts() const { return mounts_; }
|
||||||
// Limits take effect the next time the container starts.
|
// 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 setLimits(std::optional<std::uint64_t> memory, std::optional<std::uint32_t> pids, std::optional<std::uint32_t> milliCpus);
|
||||||
|
// Replaces the whole mount list; takes effect the next time the container starts.
|
||||||
|
void setMounts(const std::vector<MountSpec>& mounts);
|
||||||
|
// Runtime-only: swaps in already-resolved Bind entries (WebDav resolved to its managed mount
|
||||||
|
// directory) right before the container starts. Never persisted; the configured list (with the
|
||||||
|
// original URL) is untouched on disk. Used only by Sandbox::start.
|
||||||
|
void primeRuntimeMounts(std::vector<MountSpec> resolved) { mounts_ = std::move(resolved); }
|
||||||
|
|
||||||
void create(const NetMode& net, const std::string& image, IdMode ids, const Options& resolved);
|
void create(const NetMode& net, const std::string& image, IdMode ids, const Options& resolved);
|
||||||
void readMeta(); // meta only, no side effects
|
void readMeta(); // meta only, no side effects
|
||||||
@@ -89,6 +117,7 @@ private:
|
|||||||
std::uint32_t pids_ = 0;
|
std::uint32_t pids_ = 0;
|
||||||
std::uint32_t milliCpus_ = 0;
|
std::uint32_t milliCpus_ = 0;
|
||||||
bool relaxed_ = false;
|
bool relaxed_ = false;
|
||||||
|
std::vector<MountSpec> mounts_;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
#include "davmount.hpp"
|
||||||
|
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <poll.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/utsname.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <fstream>
|
||||||
|
#include <map>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "spec.hpp"
|
||||||
|
#include "util.hpp"
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace doos {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
fs::path stateHome() {
|
||||||
|
if (const char* h = std::getenv("DOOS_HOME"); h && *h) return h;
|
||||||
|
if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) return fs::path(x) / "doos";
|
||||||
|
if (const char* home = std::getenv("HOME"); home && *home) return fs::path(home) / ".local/share/doos";
|
||||||
|
throw std::runtime_error("HOME is not set; set DOOS_HOME");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path davRoot() { return stateHome() / "davmounts"; }
|
||||||
|
|
||||||
|
std::string hostArch() {
|
||||||
|
utsname u;
|
||||||
|
if (::uname(&u) != 0) throw SysError("uname");
|
||||||
|
std::string m = u.machine;
|
||||||
|
if (m == "x86_64") return "amd64";
|
||||||
|
if (m == "aarch64") return "arm64";
|
||||||
|
throw std::runtime_error("no rclone release for architecture " + m + "; davmount needs x86_64 or aarch64");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string trimmed(std::string s) {
|
||||||
|
while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable short id for a WebDAV target (url + credentials + vendor + insecure flag), not including
|
||||||
|
// the container-specific destination: the same target reuses one mount across containers.
|
||||||
|
std::string mountId(const MountSpec& m) {
|
||||||
|
std::string key = m.src + "\x01" + m.davUser + "\x01" + m.davPass + "\x01" + m.davVendor + "\x01" +
|
||||||
|
(m.davInsecure ? "1" : "0");
|
||||||
|
std::uint64_t h = 1469598103934665603ULL; // FNV-1a 64-bit offset basis
|
||||||
|
for (unsigned char c : key) {
|
||||||
|
h ^= c;
|
||||||
|
h *= 1099511628211ULL;
|
||||||
|
}
|
||||||
|
char buf[17];
|
||||||
|
std::snprintf(buf, sizeof buf, "%016llx", static_cast<unsigned long long>(h));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rclone's on-the-fly connection-string syntax quotes a value in "..." with internal quotes
|
||||||
|
// doubled; always quoting is safe for every field we pass (url, vendor, user, pass).
|
||||||
|
std::string quoteConnVal(const std::string& v) {
|
||||||
|
std::string out = "\"";
|
||||||
|
for (char c : v) {
|
||||||
|
if (c == '"') out += "\"\"";
|
||||||
|
else out += c;
|
||||||
|
}
|
||||||
|
out += "\"";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string buildConnString(const MountSpec& m, const std::string& obscuredPass) {
|
||||||
|
std::string vendor = m.davVendor.empty() ? "other" : m.davVendor;
|
||||||
|
std::string conn = ":webdav,url=" + quoteConnVal(m.src) + ",vendor=" + quoteConnVal(vendor);
|
||||||
|
if (!m.davUser.empty()) conn += ",user=" + quoteConnVal(m.davUser);
|
||||||
|
if (!obscuredPass.empty()) conn += ",pass=" + quoteConnVal(obscuredPass);
|
||||||
|
if (m.davInsecure) conn += ",no_check_certificate=" + quoteConnVal("true");
|
||||||
|
conn += ":";
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount point is always the 5th whitespace-separated field of a mountinfo line; the filesystem
|
||||||
|
// type is the token right after the first "-" separator (the optional-fields region before it has
|
||||||
|
// a variable length). Same technique as the CVE-2019-16884 audit check.
|
||||||
|
bool mountInfoHasReadyFuse(const fs::path& mnt) {
|
||||||
|
std::error_code ec;
|
||||||
|
std::string want = fs::exists(mnt, ec) ? fs::canonical(mnt, ec).string() : mnt.string();
|
||||||
|
std::ifstream in("/proc/self/mountinfo");
|
||||||
|
for (std::string line; std::getline(in, line);) {
|
||||||
|
std::istringstream ls(line);
|
||||||
|
std::vector<std::string> tok;
|
||||||
|
for (std::string t; ls >> t;) tok.push_back(t);
|
||||||
|
if (tok.size() < 5) continue;
|
||||||
|
if (tok[4] != want) continue;
|
||||||
|
auto it = std::find(tok.begin(), tok.end(), "-");
|
||||||
|
if (it == tok.end() || it + 1 == tok.end()) continue;
|
||||||
|
if (it[1].rfind("fuse", 0) == 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root inode stat() always succeeds under FUSE without touching the backend; only a real listing
|
||||||
|
// forces the PROPFIND round trip and surfaces auth/connectivity failures as EIO.
|
||||||
|
bool probeReadable(const fs::path& mnt) {
|
||||||
|
DIR* d = ::opendir(mnt.c_str());
|
||||||
|
if (!d) return false;
|
||||||
|
errno = 0;
|
||||||
|
(void)::readdir(d); // only errno matters here: did the backend round trip succeed
|
||||||
|
bool ok = (errno == 0);
|
||||||
|
::closedir(d);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
void unmountQuiet(const fs::path& mnt) {
|
||||||
|
std::string bin;
|
||||||
|
try {
|
||||||
|
bin = resolveBinary("fusermount3");
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
try {
|
||||||
|
bin = resolveBinary("fusermount");
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runProcess({bin, "-u", mnt.string()}, false, true);
|
||||||
|
if (mountInfoHasReadyFuse(mnt)) runProcess({bin, "-uz", mnt.string()}, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string tailOfLog(const fs::path& log) {
|
||||||
|
std::error_code ec;
|
||||||
|
if (!fs::exists(log, ec)) return "";
|
||||||
|
try {
|
||||||
|
std::string content = fsutil::readFile(log);
|
||||||
|
if (content.size() > 2000) content = content.substr(content.size() - 2000);
|
||||||
|
return content;
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string classifyFailure(const std::string& log) {
|
||||||
|
auto has = [&](const char* s) { return log.find(s) != std::string::npos; };
|
||||||
|
if (has("401") || has("Unauthorized")) return "authentication failed (check --dav-user/--dav-pass)";
|
||||||
|
if (has("x509") || has("certificate")) return "TLS certificate error (pass --dav-insecure for a trusted self-signed server)";
|
||||||
|
if (has("Unsupported method") || has("501")) return "the server does not look like a WebDAV endpoint (check the URL and --dav-vendor)";
|
||||||
|
if (has("no such host") || has("dial tcp") || has("connection refused") || has("i/o timeout") || has("Temporary failure")) {
|
||||||
|
return "cannot reach the server (check the URL and network)";
|
||||||
|
}
|
||||||
|
return log.empty() ? "mount did not become ready in time" : "see the log for details";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::map<std::string, std::string> readKv(const fs::path& p) {
|
||||||
|
std::map<std::string, std::string> kv;
|
||||||
|
std::error_code ec;
|
||||||
|
if (!fs::exists(p, ec)) return kv;
|
||||||
|
try {
|
||||||
|
std::istringstream in(fsutil::readFile(p));
|
||||||
|
for (std::string line; std::getline(in, line);) {
|
||||||
|
auto eq = line.find('=');
|
||||||
|
if (eq != std::string::npos) kv[line.substr(0, eq)] = line.substr(eq + 1);
|
||||||
|
}
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
}
|
||||||
|
return kv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloads, verifies and installs rclone under the state dir on first use; a fast version check
|
||||||
|
// short-circuits every call after that. Never resolved via PATH or a system package: doos controls
|
||||||
|
// exactly which rclone build it drives.
|
||||||
|
std::string ensureRclone() {
|
||||||
|
std::string ver(spec::kRcloneVersion);
|
||||||
|
fs::path dir = stateHome() / "tools" / ("rclone-" + ver);
|
||||||
|
fs::path bin = dir / "rclone";
|
||||||
|
|
||||||
|
auto versionOk = [&]() -> bool {
|
||||||
|
struct stat st;
|
||||||
|
if (::stat(bin.c_str(), &st) != 0 || !S_ISREG(st.st_mode) || ::access(bin.c_str(), X_OK) != 0) return false;
|
||||||
|
ProcResult v = runProcess({bin.string(), "version"}, true, true);
|
||||||
|
return v.code == 0 && v.out.rfind("rclone " + ver, 0) == 0;
|
||||||
|
};
|
||||||
|
if (versionOk()) return bin.string();
|
||||||
|
|
||||||
|
fsutil::mkdirs(dir.parent_path(), 0700);
|
||||||
|
FileLock lock(dir.parent_path() / ".install.lock", 120000);
|
||||||
|
if (versionOk()) return bin.string(); // another process installed it while we waited for the lock
|
||||||
|
|
||||||
|
std::string arch = hostArch();
|
||||||
|
std::string base = std::string(spec::kRcloneBaseUrl) + ver + "/";
|
||||||
|
std::string file = "rclone-" + ver + "-linux-" + arch + ".zip";
|
||||||
|
|
||||||
|
TempDir tmp;
|
||||||
|
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
|
||||||
|
verifyClearsignedSignature(tmp.path() / "SHA256SUMS", std::string(spec::kRcloneKeyUrl), std::string(spec::kRcloneKeyFpr));
|
||||||
|
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
|
||||||
|
std::string wantSha;
|
||||||
|
{
|
||||||
|
std::istringstream in(sums);
|
||||||
|
for (std::string line; std::getline(in, line);) {
|
||||||
|
auto sp = line.find(" ");
|
||||||
|
if (sp == std::string::npos) continue;
|
||||||
|
std::string sha = line.substr(0, sp), fn = line.substr(sp + 2);
|
||||||
|
if (fn == file && isHex64(sha)) { wantSha = sha; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wantSha.empty()) {
|
||||||
|
throw std::runtime_error("rclone " + ver + " publishes no checksum for " + file + " (unsupported architecture?)");
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchFile(base + file, tmp.path() / "rclone.zip", spec::kMaxRcloneDownloadBytes);
|
||||||
|
ProcResult h = runProcess({resolveBinary("sha256sum"), (tmp.path() / "rclone.zip").string()}, true);
|
||||||
|
std::string got = h.out.substr(0, 64);
|
||||||
|
if (h.code != 0 || got != wantSha) {
|
||||||
|
throw std::runtime_error("sha256 mismatch for rclone " + ver + ": expected " + wantSha + ", got " + got);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path extractDir = tmp.path() / "extract";
|
||||||
|
fsutil::mkdirs(extractDir);
|
||||||
|
std::string unzip;
|
||||||
|
try {
|
||||||
|
unzip = resolveBinary("unzip");
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
throw std::runtime_error("davmount needs 'unzip' installed to unpack rclone's release archive");
|
||||||
|
}
|
||||||
|
ProcResult u = runProcess({unzip, "-q", "-o", (tmp.path() / "rclone.zip").string(), "-d", extractDir.string()});
|
||||||
|
if (u.code != 0) throw std::runtime_error("unzip failed for rclone " + ver + " (exit " + std::to_string(u.code) + ")");
|
||||||
|
|
||||||
|
fs::path extracted = extractDir / ("rclone-" + ver + "-linux-" + arch) / "rclone";
|
||||||
|
if (!fs::is_regular_file(extracted)) {
|
||||||
|
throw std::runtime_error("unexpected rclone archive layout: " + extracted.string() + " not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path tmpDest = dir.parent_path() / (".tmp-rclone-" + ver);
|
||||||
|
std::error_code ec;
|
||||||
|
fs::remove_all(tmpDest, ec);
|
||||||
|
fsutil::mkdirs(tmpDest, 0755);
|
||||||
|
fs::copy_file(extracted, tmpDest / "rclone", fs::copy_options::overwrite_existing);
|
||||||
|
::chmod((tmpDest / "rclone").c_str(), 0755);
|
||||||
|
|
||||||
|
ProcResult check = runProcess({(tmpDest / "rclone").string(), "version"}, true, true);
|
||||||
|
if (check.code != 0 || check.out.rfind("rclone " + ver, 0) != 0) {
|
||||||
|
fs::remove_all(tmpDest, ec);
|
||||||
|
throw std::runtime_error("freshly installed rclone did not report version " + ver);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_all(dir, ec);
|
||||||
|
if (::rename(tmpDest.c_str(), dir.c_str()) != 0) throw SysError("install rclone into " + dir.string());
|
||||||
|
std::fprintf(stderr, "doos: installed rclone %s into %s (for --mount dav://... / --davmount)\n", ver.c_str(), bin.string().c_str());
|
||||||
|
return bin.string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-fork daemonize: the grandchild (the real rclone mount process) is reparented to init and
|
||||||
|
// outlives this doos invocation, which is the point of a davmount (a persistent host-level mount,
|
||||||
|
// not tied to one container's lifetime). The intermediate child is reaped immediately.
|
||||||
|
pid_t spawnRcloneMount(const std::string& rclone, const std::string& conn, const fs::path& mnt, const fs::path& log) {
|
||||||
|
int pipefd[2];
|
||||||
|
if (::pipe2(pipefd, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||||
|
Fd readEnd(pipefd[0]), writeEnd(pipefd[1]);
|
||||||
|
|
||||||
|
pid_t first = ::fork();
|
||||||
|
if (first < 0) throw SysError("fork");
|
||||||
|
if (first == 0) {
|
||||||
|
pid_t second = ::fork();
|
||||||
|
if (second == 0) {
|
||||||
|
::setsid();
|
||||||
|
int nul = ::open("/dev/null", O_RDONLY);
|
||||||
|
if (nul >= 0) ::dup2(nul, 0);
|
||||||
|
int lf = ::open(log.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0600);
|
||||||
|
if (lf >= 0) { ::dup2(lf, 1); ::dup2(lf, 2); }
|
||||||
|
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||||
|
// vfs-cache-mode=writes: WebDAV cannot stream, rclone's own docs recommend this. Its
|
||||||
|
// default 5s write-back is shortened to 1s so a write is not left unsent for long if
|
||||||
|
// the container that made it exits immediately after (the mount itself, and its
|
||||||
|
// pending uploads, run detached from any container and outlive it either way).
|
||||||
|
::execl(rclone.c_str(), rclone.c_str(), "mount", conn.c_str(), mnt.c_str(), "--allow-non-empty",
|
||||||
|
"--vfs-cache-mode=writes", "--vfs-write-back=1s", "--log-level=INFO", static_cast<char*>(nullptr));
|
||||||
|
::_exit(127);
|
||||||
|
}
|
||||||
|
if (second > 0) ::dprintf(writeEnd.get(), "%d\n", static_cast<int>(second));
|
||||||
|
::_exit(0);
|
||||||
|
}
|
||||||
|
writeEnd.reset();
|
||||||
|
int status = 0;
|
||||||
|
::waitpid(first, &status, 0); // reap the intermediate immediately; the daemon lives on under init
|
||||||
|
char buf[32] = {0};
|
||||||
|
ssize_t n = ::read(readEnd.get(), buf, sizeof buf - 1);
|
||||||
|
if (n <= 0) throw std::runtime_error("davmount: could not start rclone mount (no pid reported)");
|
||||||
|
return static_cast<pid_t>(std::atoi(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::path ensureMounted(const MountSpec& m) {
|
||||||
|
std::string id = mountId(m);
|
||||||
|
fs::path dir = davRoot() / id;
|
||||||
|
fsutil::mkdirs(dir, 0700);
|
||||||
|
fs::path mnt = dir / "mnt";
|
||||||
|
fsutil::mkdirs(mnt, 0700);
|
||||||
|
FileLock lock(dir / "lock", spec::kDavMountReadyTimeoutMs + spec::kLockTimeoutMs);
|
||||||
|
|
||||||
|
auto readPid = [&]() -> pid_t {
|
||||||
|
std::error_code ec;
|
||||||
|
if (!fs::exists(dir / "pid", ec)) return -1;
|
||||||
|
try {
|
||||||
|
return static_cast<pid_t>(std::stol(trimmed(fsutil::readFile(dir / "pid"))));
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mountInfoHasReadyFuse(mnt)) {
|
||||||
|
pid_t pid = readPid();
|
||||||
|
if (pid > 0 && ::kill(pid, 0) == 0 && probeReadable(mnt)) return mnt; // healthy, reuse as is
|
||||||
|
unmountQuiet(mnt); // stale: process dead or backend unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string rclone = ensureRclone();
|
||||||
|
std::string obscured;
|
||||||
|
if (!m.davPass.empty()) {
|
||||||
|
ProcResult r = runProcess({rclone, "obscure", m.davPass}, true, true);
|
||||||
|
if (r.code != 0) throw std::runtime_error("rclone could not obscure the davmount password");
|
||||||
|
obscured = trimmed(r.out);
|
||||||
|
}
|
||||||
|
std::string conn = buildConnString(m, obscured);
|
||||||
|
fs::path log = dir / "log";
|
||||||
|
pid_t daemon = spawnRcloneMount(rclone, conn, mnt, log);
|
||||||
|
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
auto deadline = Clock::now() + std::chrono::milliseconds(spec::kDavMountReadyTimeoutMs);
|
||||||
|
bool ready = false;
|
||||||
|
std::string earlyFail;
|
||||||
|
while (Clock::now() < deadline) {
|
||||||
|
if (::kill(daemon, 0) != 0) { earlyFail = tailOfLog(log); break; }
|
||||||
|
if (mountInfoHasReadyFuse(mnt) && probeReadable(mnt)) { ready = true; break; }
|
||||||
|
::poll(nullptr, 0, spec::kDavMountPollIntervalMs);
|
||||||
|
}
|
||||||
|
if (!ready) {
|
||||||
|
if (::kill(daemon, 0) == 0) { ::kill(daemon, SIGTERM); ::poll(nullptr, 0, 200); }
|
||||||
|
unmountQuiet(mnt);
|
||||||
|
std::string reason = classifyFailure(earlyFail.empty() ? tailOfLog(log) : earlyFail);
|
||||||
|
throw std::runtime_error("davmount failed for " + m.src + ": " + reason + " (log: " + log.string() + ")");
|
||||||
|
}
|
||||||
|
fsutil::writeFile(dir / "pid", std::to_string(daemon) + "\n");
|
||||||
|
fsutil::writeFile(dir / "meta", "url=" + m.src + "\nvendor=" + (m.davVendor.empty() ? "other" : m.davVendor) +
|
||||||
|
"\nuser=" + m.davUser + "\ninsecure=" + (m.davInsecure ? "1" : "0") + "\n");
|
||||||
|
return mnt;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<MountSpec> DavMount::resolveAll(const std::vector<MountSpec>& mounts) {
|
||||||
|
std::vector<MountSpec> out;
|
||||||
|
for (const auto& m : mounts) {
|
||||||
|
if (m.kind == MountKind::Bind) {
|
||||||
|
out.push_back(m);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fs::path mnt = ensureMounted(m);
|
||||||
|
MountSpec resolved;
|
||||||
|
resolved.kind = MountKind::Bind;
|
||||||
|
resolved.src = mnt.string();
|
||||||
|
resolved.dst = m.dst;
|
||||||
|
resolved.readOnly = m.readOnly;
|
||||||
|
out.push_back(resolved);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<DavMount::Status> DavMount::list() {
|
||||||
|
std::vector<Status> out;
|
||||||
|
std::error_code ec;
|
||||||
|
fs::path root = davRoot();
|
||||||
|
if (!fs::exists(root, ec)) return out;
|
||||||
|
for (const auto& e : fs::directory_iterator(root, ec)) {
|
||||||
|
if (ec || !e.is_directory()) continue;
|
||||||
|
std::string id = e.path().filename().string();
|
||||||
|
if (id.rfind(".", 0) == 0) continue;
|
||||||
|
auto kv = readKv(e.path() / "meta");
|
||||||
|
Status s;
|
||||||
|
s.id = id;
|
||||||
|
s.url = kv.count("url") ? kv["url"] : "?";
|
||||||
|
s.vendor = kv.count("vendor") ? kv["vendor"] : "other";
|
||||||
|
s.mountpoint = (e.path() / "mnt").string();
|
||||||
|
s.mounted = mountInfoHasReadyFuse(e.path() / "mnt");
|
||||||
|
out.push_back(std::move(s));
|
||||||
|
if (out.size() >= spec::kMaxDavTargets) break;
|
||||||
|
}
|
||||||
|
std::sort(out.begin(), out.end(), [](const Status& a, const Status& b) { return a.id < b.id; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string DavMount::umount(const std::string& id, bool confirmed) {
|
||||||
|
fs::path root = davRoot();
|
||||||
|
fs::path dir = root / id;
|
||||||
|
std::error_code ec;
|
||||||
|
if (!fs::is_directory(dir, ec)) throw std::runtime_error("no such davmount '" + id + "'");
|
||||||
|
if (fs::canonical(dir, ec) != fs::canonical(root, ec) / id) {
|
||||||
|
throw std::runtime_error("refusing to act outside the davmounts store: " + dir.string());
|
||||||
|
}
|
||||||
|
if (!confirmed) {
|
||||||
|
return "dry run: would unmount and remove " + dir.string() + " (stops storage for any container still using it). Add --yes to confirm.";
|
||||||
|
}
|
||||||
|
FileLock lock(dir / "lock", spec::kLockTimeoutMs);
|
||||||
|
unmountQuiet(dir / "mnt");
|
||||||
|
fsutil::removeTree(dir);
|
||||||
|
return "unmounted " + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace doos
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "container.hpp"
|
||||||
|
|
||||||
|
namespace doos {
|
||||||
|
|
||||||
|
// Turns a --mount/--davmount WebDAV entry into a ready, already-mounted host directory, then hands
|
||||||
|
// back a plain Bind entry pointing at it; everything downstream (Sandbox, mountExtra) treats it
|
||||||
|
// exactly like an ordinary --mount SRC:DST bind, unaware that rclone is involved at all.
|
||||||
|
//
|
||||||
|
// doos never speaks WebDAV itself: it drives a managed rclone binary, downloaded once, verified
|
||||||
|
// against a pinned signing key and installed under the state dir (never the system or the
|
||||||
|
// caller's PATH), the same trust model as Alpine/Ubuntu images. Mounts are deduplicated by target
|
||||||
|
// (url + credentials + vendor + insecure flag) and reused across containers and attaches: the FUSE
|
||||||
|
// mount lives on the host, outside any container's namespaces, and outlives the doos process that
|
||||||
|
// created it.
|
||||||
|
class DavMount {
|
||||||
|
public:
|
||||||
|
// Resolves every WebDav entry in `mounts`; Bind entries pass through unchanged. Installs rclone
|
||||||
|
// and mounts on demand; reuses an already-healthy mount. Throws a specific, human-readable
|
||||||
|
// reason (auth, connectivity, TLS, protocol) on failure, never a bare "mount failed".
|
||||||
|
static std::vector<MountSpec> resolveAll(const std::vector<MountSpec>& mounts);
|
||||||
|
|
||||||
|
struct Status {
|
||||||
|
std::string id; // directory name under $DOOS_HOME/davmounts, stable per target
|
||||||
|
std::string url;
|
||||||
|
std::string vendor;
|
||||||
|
std::string mountpoint;
|
||||||
|
bool mounted = false;
|
||||||
|
};
|
||||||
|
// `doos davmount list`.
|
||||||
|
static std::vector<Status> list();
|
||||||
|
// `doos davmount umount [--yes] ID`. Dry-run unless confirmed, like `delete`/`image delete`.
|
||||||
|
static std::string umount(const std::string& id, bool confirmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace doos
|
||||||
+16
-14
@@ -20,6 +20,16 @@ namespace doos {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
// Same trusted-path resolution as every other external helper (resolveBinary in util.cpp): the
|
||||||
|
// state dir's bin/ override, then a fixed list of system directories, never the caller's PATH.
|
||||||
|
std::string findBinary(const char* name) {
|
||||||
|
try {
|
||||||
|
return resolveBinary(name);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void writeProcFile(pid_t pid, const char* file, const std::string& content) {
|
void writeProcFile(pid_t pid, const char* file, const std::string& content) {
|
||||||
std::string path = "/proc/" + std::to_string(pid) + "/" + file;
|
std::string path = "/proc/" + std::to_string(pid) + "/" + file;
|
||||||
int fd = ::open(path.c_str(), O_WRONLY | O_CLOEXEC);
|
int fd = ::open(path.c_str(), O_WRONLY | O_CLOEXEC);
|
||||||
@@ -30,16 +40,6 @@ void writeProcFile(pid_t pid, const char* file, const std::string& content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool onPath(std::string_view bin) {
|
|
||||||
const char* path = std::getenv("PATH");
|
|
||||||
std::stringstream ss(path ? path : "/usr/bin:/bin");
|
|
||||||
for (std::string dir; std::getline(ss, dir, ':');) {
|
|
||||||
std::string full = dir + "/" + std::string(bin);
|
|
||||||
if (::access(full.c_str(), X_OK) == 0) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
IdMode IdMap::parse(const std::string& s) {
|
IdMode IdMap::parse(const std::string& s) {
|
||||||
@@ -71,7 +71,8 @@ std::optional<SubRange> IdMap::range(const char* file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool IdMap::subuidAvailable() {
|
bool IdMap::subuidAvailable() {
|
||||||
return range("/etc/subuid") && range("/etc/subgid") && onPath(spec::kNewUidMap) && onPath(spec::kNewGidMap);
|
return range("/etc/subuid") && range("/etc/subgid") && !findBinary("newuidmap").empty() &&
|
||||||
|
!findBinary("newgidmap").empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
IdMode IdMap::best() { return subuidAvailable() ? IdMode::Subuid : IdMode::Single; }
|
IdMode IdMap::best() { return subuidAvailable() ? IdMode::Subuid : IdMode::Single; }
|
||||||
@@ -86,15 +87,16 @@ void IdMap::apply(pid_t pid, IdMode mode) {
|
|||||||
}
|
}
|
||||||
auto u = range("/etc/subuid");
|
auto u = range("/etc/subuid");
|
||||||
auto g = range("/etc/subgid");
|
auto g = range("/etc/subgid");
|
||||||
if (!u || !g || !onPath(spec::kNewUidMap) || !onPath(spec::kNewGidMap)) {
|
std::string newuidmap = findBinary("newuidmap"), newgidmap = findBinary("newgidmap");
|
||||||
|
if (!u || !g || newuidmap.empty() || newgidmap.empty()) {
|
||||||
throw std::runtime_error("subuid mapping unavailable: need /etc/subuid, /etc/subgid, newuidmap and newgidmap "
|
throw std::runtime_error("subuid mapping unavailable: need /etc/subuid, /etc/subgid, newuidmap and newgidmap "
|
||||||
"(install uidmap, or use --ids single)");
|
"(install uidmap, or use --ids single)");
|
||||||
}
|
}
|
||||||
auto n = [](const SubRange& r) { return std::to_string(std::min<unsigned long>(r.count, spec::kMaxIdRange - 1)); };
|
auto n = [](const SubRange& r) { return std::to_string(std::min<unsigned long>(r.count, spec::kMaxIdRange - 1)); };
|
||||||
std::string p = std::to_string(pid);
|
std::string p = std::to_string(pid);
|
||||||
ProcResult a = runProcess({std::string(spec::kNewUidMap), p, "0", uid, "1", "1", std::to_string(u->start), n(*u)});
|
ProcResult a = runProcess({newuidmap, p, "0", uid, "1", "1", std::to_string(u->start), n(*u)});
|
||||||
if (a.code != 0) throw std::runtime_error("newuidmap failed (exit " + std::to_string(a.code) + ")");
|
if (a.code != 0) throw std::runtime_error("newuidmap failed (exit " + std::to_string(a.code) + ")");
|
||||||
ProcResult b = runProcess({std::string(spec::kNewGidMap), p, "0", gid, "1", "1", std::to_string(g->start), n(*g)});
|
ProcResult b = runProcess({newgidmap, p, "0", gid, "1", "1", std::to_string(g->start), n(*g)});
|
||||||
if (b.code != 0) throw std::runtime_error("newgidmap failed (exit " + std::to_string(b.code) + ")");
|
if (b.code != 0) throw std::runtime_error("newgidmap failed (exit " + std::to_string(b.code) + ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-100
@@ -30,93 +30,6 @@ std::string cpuArch(bool debianStyle) {
|
|||||||
throw std::runtime_error("no catalog images for architecture " + m + "; use a file or URL with --sha256");
|
throw std::runtime_error("no catalog images for architecture " + m + "; use a file or URL with --sha256");
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string fetchText(const std::string& url) {
|
|
||||||
ProcResult r = runProcess({"curl", "--fail", "--silent", "--show-error", "--location", "--proto", "=https",
|
|
||||||
"--max-time", "60", url},
|
|
||||||
true);
|
|
||||||
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
|
||||||
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); });
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<unsigned long> versionParts(const std::string& v) {
|
std::vector<unsigned long> versionParts(const std::string& v) {
|
||||||
std::vector<unsigned long> out;
|
std::vector<unsigned long> out;
|
||||||
std::stringstream ss(v);
|
std::stringstream ss(v);
|
||||||
@@ -201,7 +114,7 @@ ImageSource Catalog::resolve(const std::string& n) {
|
|||||||
TempDir tmp;
|
TempDir tmp;
|
||||||
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
|
fetchFile(base + "SHA256SUMS", tmp.path() / "SHA256SUMS");
|
||||||
fetchFile(base + "SHA256SUMS.gpg", tmp.path() / "SHA256SUMS.gpg");
|
fetchFile(base + "SHA256SUMS.gpg", tmp.path() / "SHA256SUMS.gpg");
|
||||||
verifySignature(tmp.path() / "SHA256SUMS", tmp.path() / "SHA256SUMS.gpg", std::string(spec::kUbuntuKeyUrl),
|
verifyDetachedSignature(tmp.path() / "SHA256SUMS", tmp.path() / "SHA256SUMS.gpg", std::string(spec::kUbuntuKeyUrl),
|
||||||
std::string(spec::kUbuntuKeyFpr));
|
std::string(spec::kUbuntuKeyFpr));
|
||||||
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
|
std::string sums = fsutil::readFile(tmp.path() / "SHA256SUMS");
|
||||||
std::string suffix = "-base-" + arch + ".tar.gz";
|
std::string suffix = "-base-" + arch + ".tar.gz";
|
||||||
@@ -289,10 +202,13 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
|||||||
if (count >= spec::kMaxImages) {
|
if (count >= spec::kMaxImages) {
|
||||||
throw std::runtime_error("image limit reached (" + std::to_string(spec::kMaxImages) + "); delete one first");
|
throw std::runtime_error("image limit reached (" + std::to_string(spec::kMaxImages) + "); delete one first");
|
||||||
}
|
}
|
||||||
for (const char* tool : {"curl", "tar", "sha256sum"}) {
|
std::string curl, tar, sha256sum;
|
||||||
if (runProcess({"sh", "-c", std::string("command -v ") + tool}, true, true).code != 0) {
|
try {
|
||||||
throw std::runtime_error(std::string("required tool not found: ") + tool);
|
curl = resolveBinary("curl");
|
||||||
}
|
tar = resolveBinary("tar");
|
||||||
|
sha256sum = resolveBinary("sha256sum");
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
throw std::runtime_error(std::string("required tool missing: ") + e.what());
|
||||||
}
|
}
|
||||||
fs::path tmp = root_ / (".tmp-" + src.name);
|
fs::path tmp = root_ / (".tmp-" + src.name);
|
||||||
{
|
{
|
||||||
@@ -312,13 +228,13 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
|||||||
} else {
|
} else {
|
||||||
archive = tmp / "download";
|
archive = tmp / "download";
|
||||||
std::fprintf(stderr, "downloading %s\n", src.location.c_str());
|
std::fprintf(stderr, "downloading %s\n", src.location.c_str());
|
||||||
ProcResult d = runProcess({"curl", "--fail", "--location", "--proto", "=https", "--progress-bar",
|
ProcResult d = runProcess({curl, "--fail", "--location", "--proto", "=https", "--progress-bar",
|
||||||
"--max-filesize", std::to_string(spec::kMaxImageDownloadBytes), "--max-time",
|
"--max-filesize", std::to_string(spec::kMaxImageDownloadBytes), "--max-time",
|
||||||
std::to_string(spec::kDownloadTimeoutSec), "--output", archive.string(), src.location});
|
std::to_string(spec::kDownloadTimeoutSec), "--output", archive.string(), src.location});
|
||||||
if (d.code != 0) throw std::runtime_error("download failed (curl exit " + std::to_string(d.code) + ")");
|
if (d.code != 0) throw std::runtime_error("download failed (curl exit " + std::to_string(d.code) + ")");
|
||||||
}
|
}
|
||||||
if (!src.sha256.empty()) {
|
if (!src.sha256.empty()) {
|
||||||
ProcResult h = runProcess({"sha256sum", archive.string()}, true);
|
ProcResult h = runProcess({sha256sum, archive.string()}, true);
|
||||||
std::string got = h.out.substr(0, 64);
|
std::string got = h.out.substr(0, 64);
|
||||||
if (h.code != 0 || got != lowerCase(src.sha256)) {
|
if (h.code != 0 || got != lowerCase(src.sha256)) {
|
||||||
throw std::runtime_error("sha256 mismatch for " + src.location + ": expected " + src.sha256 + ", got " + got);
|
throw std::runtime_error("sha256 mismatch for " + src.location + ": expected " + src.sha256 + ", got " + got);
|
||||||
@@ -327,22 +243,22 @@ ImageInfo ImageStore::add(const ImageSource& src, IdMode ids) {
|
|||||||
if (!src.sigUrl.empty()) {
|
if (!src.sigUrl.empty()) {
|
||||||
TempDir sigDir;
|
TempDir sigDir;
|
||||||
fetchFile(src.sigUrl, sigDir.path() / "file.asc");
|
fetchFile(src.sigUrl, sigDir.path() / "file.asc");
|
||||||
verifySignature(archive, sigDir.path() / "file.asc", src.keyUrl, src.keyFpr);
|
verifyDetachedSignature(archive, sigDir.path() / "file.asc", src.keyUrl, src.keyFpr);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
std::fprintf(stderr, "note: local file used without --sha256, not verified\n");
|
std::fprintf(stderr, "note: local file used without --sha256, not verified\n");
|
||||||
}
|
}
|
||||||
fs::path root = tmp / "rootfs";
|
fs::path root = tmp / "rootfs";
|
||||||
fsutil::mkdirs(root, 0755);
|
fsutil::mkdirs(root, 0755);
|
||||||
std::vector<std::string> tar = {"tar", "-x", "-p", "--numeric-owner", "--exclude=./dev/*", "--exclude=dev/*",
|
std::vector<std::string> tarArgv = {tar, "-x", "-p", "--numeric-owner", "--exclude=./dev/*", "--exclude=dev/*",
|
||||||
"-f", archive.string(), "-C", root.string()};
|
"-f", archive.string(), "-C", root.string()};
|
||||||
if (ids == IdMode::Single) tar.insert(tar.begin() + 1, "--no-same-owner");
|
if (ids == IdMode::Single) tarArgv.insert(tarArgv.begin() + 1, "--no-same-owner");
|
||||||
std::fprintf(stderr, "extracting (ids: %s)\n", IdMap::name(ids).c_str());
|
std::fprintf(stderr, "extracting (ids: %s)\n", IdMap::name(ids).c_str());
|
||||||
int rc = UserNsJob::run(ids, [&] {
|
int rc = UserNsJob::run(ids, [&] {
|
||||||
std::vector<char*> a;
|
std::vector<char*> a;
|
||||||
for (auto& s : tar) a.push_back(s.data());
|
for (auto& s : tarArgv) a.push_back(s.data());
|
||||||
a.push_back(nullptr);
|
a.push_back(nullptr);
|
||||||
::execvp(a[0], a.data());
|
::execv(a[0], a.data());
|
||||||
return 127;
|
return 127;
|
||||||
});
|
});
|
||||||
if (rc != 0) throw std::runtime_error("tar failed (exit " + std::to_string(rc) + ")");
|
if (rc != 0) throw std::runtime_error("tar failed (exit " + std::to_string(rc) + ")");
|
||||||
|
|||||||
+20
-4
@@ -50,6 +50,12 @@ std::string Network::resolvConf(NetMode m) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void SlirpSession::start(pid_t targetPid) {
|
void SlirpSession::start(pid_t targetPid) {
|
||||||
|
std::string bin;
|
||||||
|
try {
|
||||||
|
bin = resolveBinary("slirp4netns");
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
throw std::runtime_error("slirp4netns helper not found (install slirp4netns or use --net none)");
|
||||||
|
}
|
||||||
int ready[2];
|
int ready[2];
|
||||||
if (::pipe2(ready, O_CLOEXEC) != 0) throw SysError("pipe");
|
if (::pipe2(ready, O_CLOEXEC) != 0) throw SysError("pipe");
|
||||||
Fd readEnd(ready[0]), writeEnd(ready[1]);
|
Fd readEnd(ready[0]), writeEnd(ready[1]);
|
||||||
@@ -58,14 +64,16 @@ void SlirpSession::start(pid_t targetPid) {
|
|||||||
if (pid_ < 0) throw SysError("fork slirp4netns");
|
if (pid_ < 0) throw SysError("fork slirp4netns");
|
||||||
if (pid_ == 0) {
|
if (pid_ == 0) {
|
||||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||||
|
// Do not trust the caller's PATH: only system directories may be searched by anything the helper runs.
|
||||||
|
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||||
int nul = ::open("/dev/null", O_WRONLY);
|
int nul = ::open("/dev/null", O_WRONLY);
|
||||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||||
int wfd = ::dup(writeEnd.get()); // dup clears CLOEXEC
|
int wfd = ::dup(writeEnd.get()); // dup clears CLOEXEC
|
||||||
std::string readyArg = "--ready-fd=" + std::to_string(wfd);
|
std::string readyArg = "--ready-fd=" + std::to_string(wfd);
|
||||||
std::string target = std::to_string(targetPid);
|
std::string target = std::to_string(targetPid);
|
||||||
std::string bin(spec::kSlirpBinary), tap(spec::kSlirpTapName);
|
std::string tap(spec::kSlirpTapName);
|
||||||
::execlp(bin.c_str(), bin.c_str(), "--configure", "--mtu=65520", "--disable-host-loopback",
|
::execl(bin.c_str(), bin.c_str(), "--configure", "--mtu=65520", "--disable-host-loopback",
|
||||||
readyArg.c_str(), target.c_str(), tap.c_str(), static_cast<char*>(nullptr));
|
readyArg.c_str(), target.c_str(), tap.c_str(), static_cast<char*>(nullptr));
|
||||||
::dprintf(2, "doos: cannot exec %s: %s\n", bin.c_str(), std::strerror(errno));
|
::dprintf(2, "doos: cannot exec %s: %s\n", bin.c_str(), std::strerror(errno));
|
||||||
::_exit(127);
|
::_exit(127);
|
||||||
}
|
}
|
||||||
@@ -92,14 +100,22 @@ void SlirpSession::stop() {
|
|||||||
namespace doos {
|
namespace doos {
|
||||||
|
|
||||||
void PastaSession::start(pid_t targetPid) {
|
void PastaSession::start(pid_t targetPid) {
|
||||||
|
std::string bin;
|
||||||
|
try {
|
||||||
|
bin = resolveBinary("pasta");
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
throw std::runtime_error("pasta helper not found (install pasta or use --net none)");
|
||||||
|
}
|
||||||
pid_ = ::fork();
|
pid_ = ::fork();
|
||||||
if (pid_ < 0) throw SysError("fork pasta");
|
if (pid_ < 0) throw SysError("fork pasta");
|
||||||
if (pid_ == 0) {
|
if (pid_ == 0) {
|
||||||
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
::prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||||
|
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||||
int nul = ::open("/dev/null", O_WRONLY);
|
int nul = ::open("/dev/null", O_WRONLY);
|
||||||
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
if (nul >= 0) { ::dup2(nul, 1); ::dup2(nul, 2); }
|
||||||
std::string target = std::to_string(targetPid);
|
std::string target = std::to_string(targetPid);
|
||||||
::execlp("pasta", "pasta", "--foreground", "--quiet", "--config-net", target.c_str(), static_cast<char*>(nullptr));
|
::execl(bin.c_str(), bin.c_str(), "--foreground", "--quiet", "--config-net", target.c_str(),
|
||||||
|
static_cast<char*>(nullptr));
|
||||||
::_exit(127);
|
::_exit(127);
|
||||||
}
|
}
|
||||||
// pasta has no ready fd: wait until the target namespace has an interface besides lo.
|
// pasta has no ready fd: wait until the target namespace has an interface besides lo.
|
||||||
|
|||||||
+49
-10
@@ -23,6 +23,7 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
|
||||||
#include "cgroup.hpp"
|
#include "cgroup.hpp"
|
||||||
|
#include "davmount.hpp"
|
||||||
#include "hardening.hpp"
|
#include "hardening.hpp"
|
||||||
#include "spec.hpp"
|
#include "spec.hpp"
|
||||||
#include "util.hpp"
|
#include "util.hpp"
|
||||||
@@ -130,16 +131,26 @@ void mountHostLayers(const Container& c, const fs::path& root) {
|
|||||||
for (auto d : spec::kCompatDirs) {
|
for (auto d : spec::kCompatDirs) {
|
||||||
std::error_code e;
|
std::error_code e;
|
||||||
std::string host(d);
|
std::string host(d);
|
||||||
auto st = fs::symlink_status(host, e);
|
// Read any symlink through an fd opened on its parent so the target cannot be swapped
|
||||||
if (e) continue;
|
// between the check and the recreation (TOCTOU). Real directories are overlaid; absent
|
||||||
if (fs::is_symlink(st)) {
|
// entries are skipped. Symlinks whose target we cannot pin are not recreated: fail closed.
|
||||||
fs::path target = fs::read_symlink(host);
|
fs::path parent = fs::path(host).parent_path();
|
||||||
fs::path link = root / host.substr(1);
|
int pfd = ::open(parent.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||||
fs::remove(link, e);
|
if (pfd < 0) continue;
|
||||||
fs::create_symlink(target, link);
|
Fd guard(pfd);
|
||||||
} else if (fs::is_directory(st)) {
|
std::string base = fs::path(host).filename().string();
|
||||||
mountOverlay(c, root, d);
|
char target[4096];
|
||||||
|
ssize_t n = ::readlinkat(pfd, base.c_str(), target, sizeof target - 1);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINVAL || errno == ENOENT) {
|
||||||
|
if (fs::is_directory(host, e) && !e) mountOverlay(c, root, d);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
target[n] = '\0';
|
||||||
|
fs::path link = root / host.substr(1);
|
||||||
|
fs::remove(link, e);
|
||||||
|
fs::create_symlink(target, link);
|
||||||
}
|
}
|
||||||
for (auto d : spec::kPrivateDirs) doMount(c.privateDir(std::string(d)).c_str(), sub(root, d), nullptr, MS_BIND);
|
for (auto d : spec::kPrivateDirs) doMount(c.privateDir(std::string(d)).c_str(), sub(root, d), nullptr, MS_BIND);
|
||||||
// overlays that live inside a private dir must be mounted after it
|
// overlays that live inside a private dir must be mounted after it
|
||||||
@@ -156,6 +167,19 @@ void mountImageRoot(const Container& c, const fs::path& root, const fs::path& im
|
|||||||
for (auto d : spec::kEmptyDirs) fsutil::mkdirs(sub(root, d));
|
for (auto d : spec::kEmptyDirs) fsutil::mkdirs(sub(root, d));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --mount: extra bind mounts beyond the overlay/image root, outside the --size cap.
|
||||||
|
void mountExtra(const Container& c, const fs::path& root) {
|
||||||
|
for (const auto& m : c.mounts()) {
|
||||||
|
if (!fs::is_directory(m.src)) {
|
||||||
|
throw std::runtime_error("mount source '" + m.src + "' is not a directory (mount it on the host first)");
|
||||||
|
}
|
||||||
|
std::string target = sub(root, m.dst);
|
||||||
|
fsutil::mkdirs(target);
|
||||||
|
doMount(m.src.c_str(), target, nullptr, MS_BIND | MS_REC);
|
||||||
|
if (m.readOnly) remountReadOnly(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setupRoot(const Container& c, const fs::path& imageRoot) {
|
void setupRoot(const Container& c, const fs::path& imageRoot) {
|
||||||
fs::path root = c.rootfs();
|
fs::path root = c.rootfs();
|
||||||
doMount(nullptr, "/", nullptr, MS_REC | MS_PRIVATE);
|
doMount(nullptr, "/", nullptr, MS_REC | MS_PRIVATE);
|
||||||
@@ -192,6 +216,8 @@ void setupRoot(const Container& c, const fs::path& imageRoot) {
|
|||||||
for (auto p : spec::kProcReadOnly) remountReadOnly(sub(root, p));
|
for (auto p : spec::kProcReadOnly) remountReadOnly(sub(root, p));
|
||||||
for (auto p : spec::kProcMasked) doMount((root / "dev/null").c_str(), sub(root, p), nullptr, MS_BIND);
|
for (auto p : spec::kProcMasked) doMount((root / "dev/null").c_str(), sub(root, p), nullptr, MS_BIND);
|
||||||
|
|
||||||
|
mountExtra(c, root);
|
||||||
|
|
||||||
// Switch root, then drop the old one.
|
// Switch root, then drop the old one.
|
||||||
if (::chdir(root.c_str()) != 0) throw SysError("chdir newroot");
|
if (::chdir(root.c_str()) != 0) throw SysError("chdir newroot");
|
||||||
if (::syscall(SYS_pivot_root, ".", ".") != 0) throw SysError("pivot_root");
|
if (::syscall(SYS_pivot_root, ".", ".") != 0) throw SysError("pivot_root");
|
||||||
@@ -269,6 +295,10 @@ int containerInit(void* arg) {
|
|||||||
setupRoot(c, ctx->imageRoot);
|
setupRoot(c, ctx->imageRoot);
|
||||||
if (::sethostname(c.name().c_str(), c.name().size()) != 0) throw SysError("sethostname");
|
if (::sethostname(c.name().c_str(), c.name().size()) != 0) throw SysError("sethostname");
|
||||||
if (Network::isolated(c.net())) bringLoopbackUp();
|
if (Network::isolated(c.net())) bringLoopbackUp();
|
||||||
|
// Hardening (inside applyLimitsAndEnv) must be the last thing before the worker is forked
|
||||||
|
// and exec'd: everything this process still needs to do for itself (mounts, hostname,
|
||||||
|
// loopback) has to happen first, since its seccomp filter and dropped capabilities are
|
||||||
|
// inherited by every descendant from here on. See the matching note in Sandbox::join.
|
||||||
applyLimitsAndEnv(c);
|
applyLimitsAndEnv(c);
|
||||||
|
|
||||||
pid_t worker = ::fork();
|
pid_t worker = ::fork();
|
||||||
@@ -368,6 +398,9 @@ int Sandbox::start(FileLock& lock) {
|
|||||||
throw std::runtime_error("image '" + container_.image() + "' is missing; add it again with: doos image add");
|
throw std::runtime_error("image '" + container_.image() + "' is missing; add it again with: doos image add");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// davmount entries are resolved here, on the host, before any namespace exists: the FUSE mount
|
||||||
|
// this installs (or reuses) lives outside the container entirely and must outlast it.
|
||||||
|
container_.primeRuntimeMounts(DavMount::resolveAll(container_.mounts()));
|
||||||
int flags = CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD;
|
int flags = CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD;
|
||||||
if (Network::isolated(net)) flags |= CLONE_NEWNET;
|
if (Network::isolated(net)) flags |= CLONE_NEWNET;
|
||||||
|
|
||||||
@@ -532,6 +565,11 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
|
|||||||
if (cgroup) {
|
if (cgroup) {
|
||||||
try { cgroup->addShell(::getpid()); } catch (const std::exception&) { /* limits only; keep going */ }
|
try { cgroup->addShell(::getpid()); } catch (const std::exception&) { /* limits only; keep going */ }
|
||||||
}
|
}
|
||||||
|
// Ordering invariant: this setns loop must run before Hardening::apply() (inside
|
||||||
|
// applyLimitsAndEnv below), never after. The seccomp filter it installs denies
|
||||||
|
// SYS_setns outright (hardening.cpp's deniedSyscalls), and a seccomp filter is
|
||||||
|
// inherited by every descendant for the rest of its life. Moving the hardening call
|
||||||
|
// earlier would make every second `attach` fail to join a running container.
|
||||||
for (int i = 0; i < 6; ++i) {
|
for (int i = 0; i < 6; ++i) {
|
||||||
if (fds[i].valid() && ::setns(fds[i].get(), kinds[i]) != 0) {
|
if (fds[i].valid() && ::setns(fds[i].get(), kinds[i]) != 0) {
|
||||||
throw SysError(std::string("setns ") + names[i]);
|
throw SysError(std::string("setns ") + names[i]);
|
||||||
@@ -539,7 +577,8 @@ int Sandbox::join(pid_t initPid, FileLock& lock) {
|
|||||||
}
|
}
|
||||||
if (::fchdir(rootFd.get()) != 0 || ::chroot(".") != 0) throw SysError("chroot into container");
|
if (::fchdir(rootFd.get()) != 0 || ::chroot(".") != 0) throw SysError("chroot into container");
|
||||||
if (::chdir(std::string(spec::kHome).c_str()) != 0) throw SysError("chdir home");
|
if (::chdir(std::string(spec::kHome).c_str()) != 0) throw SysError("chdir home");
|
||||||
// setns(pid) only affects children, so fork once more.
|
// setns(pid) only affects children, so fork once more. Hardening (which the setns
|
||||||
|
// loop above must predate) is applied only in this inner child, right before exec.
|
||||||
pid_t inner = ::fork();
|
pid_t inner = ::fork();
|
||||||
if (inner < 0) throw SysError("fork");
|
if (inner < 0) throw SysError("fork");
|
||||||
if (inner == 0) {
|
if (inner == 0) {
|
||||||
|
|||||||
+21
-4
@@ -12,6 +12,23 @@ constexpr std::size_t kMaxContainers = 64;
|
|||||||
constexpr std::size_t kMaxNameLen = 32;
|
constexpr std::size_t kMaxNameLen = 32;
|
||||||
constexpr std::size_t kMaxCommandArgs = 256;
|
constexpr std::size_t kMaxCommandArgs = 256;
|
||||||
constexpr std::size_t kMaxTreeDepth = 256; // removal recursion cap
|
constexpr std::size_t kMaxTreeDepth = 256; // removal recursion cap
|
||||||
|
constexpr std::size_t kMaxMounts = 8; // extra --mount bind mounts per container
|
||||||
|
|
||||||
|
// davmount: --mount/--davmount with a dav://, davs://, http:// or https:// source. rclone is never
|
||||||
|
// taken from the caller's PATH or a system package; it is downloaded once, verified, and installed
|
||||||
|
// under the state dir, exactly like curl/tar/gpg are only ever resolved via resolveBinary(), never
|
||||||
|
// trusted from an ambient install. Pinned the same way Alpine/Ubuntu images are: a fixed version,
|
||||||
|
// fetched over https, checked against a signed checksum file and a key pinned by fingerprint.
|
||||||
|
constexpr std::string_view kRcloneVersion = "v1.75.2";
|
||||||
|
constexpr std::string_view kRcloneBaseUrl = "https://downloads.rclone.org/";
|
||||||
|
constexpr std::string_view kRcloneKeyUrl = "https://rclone.org/KEYS";
|
||||||
|
constexpr std::string_view kRcloneKeyFpr = "FBF737ECE9F8AB18604BD2AC93935E02FF3B54FA";
|
||||||
|
constexpr std::uint64_t kMaxRcloneDownloadBytes = 128ULL << 20;
|
||||||
|
constexpr int kDavMountReadyTimeoutMs = 20000; // bounded wait for a webdav mount to answer
|
||||||
|
constexpr int kDavMountPollIntervalMs = 100;
|
||||||
|
constexpr std::size_t kMaxDavTargets = 32; // distinct webdav mounts doos manages at once
|
||||||
|
constexpr std::array<std::string_view, 5> kDavVendors = {"other", "nextcloud", "owncloud", "sharepoint",
|
||||||
|
"sharepoint-ntlm"};
|
||||||
constexpr unsigned long kMaxOpenFiles = 4096; // RLIMIT_NOFILE ceiling inside
|
constexpr unsigned long kMaxOpenFiles = 4096; // RLIMIT_NOFILE ceiling inside
|
||||||
constexpr int kNetReadyTimeoutMs = 5000; // slirp4netns must be ready by then
|
constexpr int kNetReadyTimeoutMs = 5000; // slirp4netns must be ready by then
|
||||||
constexpr int kLockTimeoutMs = 5000; // per-container lock wait
|
constexpr int kLockTimeoutMs = 5000; // per-container lock wait
|
||||||
@@ -47,9 +64,9 @@ constexpr std::size_t kMaxEtcCopyFiles = 50000;
|
|||||||
constexpr std::uint64_t kMaxEtcCopyBytes = 256ULL << 20;
|
constexpr std::uint64_t kMaxEtcCopyBytes = 256ULL << 20;
|
||||||
|
|
||||||
// Id mapping: container ids 0..kMaxIdRange. 0 maps to the invoking user, the rest to the subuid range.
|
// Id mapping: container ids 0..kMaxIdRange. 0 maps to the invoking user, the rest to the subuid range.
|
||||||
|
// newuidmap/newgidmap, like every other external helper, are found via resolveBinary() (util.cpp):
|
||||||
|
// the state dir's bin/ override, then a fixed list of system directories, never the caller's PATH.
|
||||||
constexpr std::uint32_t kMaxIdRange = 65536;
|
constexpr std::uint32_t kMaxIdRange = 65536;
|
||||||
constexpr std::string_view kNewUidMap = "newuidmap";
|
|
||||||
constexpr std::string_view kNewGidMap = "newgidmap";
|
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
constexpr std::size_t kMaxImages = 16;
|
constexpr std::size_t kMaxImages = 16;
|
||||||
@@ -70,8 +87,8 @@ constexpr std::string_view kUbuntuKeyUrl =
|
|||||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||||
constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
constexpr std::string_view kUbuntuKeyFpr = "843938DF228D22F7B3742BC0D94AA3F0EFE21092";
|
||||||
|
|
||||||
// Networking
|
// Networking. slirp4netns and pasta are found via resolveBinary() (util.cpp), same as every other
|
||||||
constexpr std::string_view kSlirpBinary = "slirp4netns";
|
// external helper: the state dir's bin/ override, then a fixed list of system directories.
|
||||||
constexpr std::string_view kSlirpDns = "10.0.2.3";
|
constexpr std::string_view kSlirpDns = "10.0.2.3";
|
||||||
constexpr std::string_view kSlirpTapName = "tap0";
|
constexpr std::string_view kSlirpTapName = "tap0";
|
||||||
|
|
||||||
|
|||||||
+196
-11
@@ -8,10 +8,12 @@
|
|||||||
#include <dirent.h>
|
#include <dirent.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <limits>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
@@ -61,11 +63,19 @@ std::uint64_t parseSize(const std::string& text) {
|
|||||||
if (unit == "b" || unit.empty()) {
|
if (unit == "b" || unit.empty()) {
|
||||||
} else {
|
} else {
|
||||||
if (unit.size() == 2 && unit[1] == 'b') unit.pop_back();
|
if (unit.size() == 2 && unit[1] == 'b') unit.pop_back();
|
||||||
if (unit == "k") v <<= 10;
|
int shift;
|
||||||
else if (unit == "m") v <<= 20;
|
if (unit == "k") shift = 10;
|
||||||
else if (unit == "g") v <<= 30;
|
else if (unit == "m") shift = 20;
|
||||||
else if (unit == "t") v <<= 40;
|
else if (unit == "g") shift = 30;
|
||||||
|
else if (unit == "t") shift = 40;
|
||||||
else throw std::runtime_error("invalid size unit in '" + text + "' (use k, m, g or t)");
|
else throw std::runtime_error("invalid size unit in '" + text + "' (use k, m, g or t)");
|
||||||
|
// A 64-bit shift wraps silently on a large enough value instead of producing a huge
|
||||||
|
// number; reject anything that would not survive the shift intact rather than let it
|
||||||
|
// land, by chance, inside whatever range the caller validates against next.
|
||||||
|
if (v > (std::numeric_limits<std::uint64_t>::max() >> shift)) {
|
||||||
|
throw std::runtime_error("size '" + text + "' is too large");
|
||||||
|
}
|
||||||
|
v <<= shift;
|
||||||
}
|
}
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
@@ -116,6 +126,8 @@ ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool q
|
|||||||
int nul = ::open("/dev/null", O_WRONLY);
|
int nul = ::open("/dev/null", O_WRONLY);
|
||||||
if (nul >= 0) { ::dup2(nul, 2); if (!capture) ::dup2(nul, 1); }
|
if (nul >= 0) { ::dup2(nul, 2); if (!capture) ::dup2(nul, 1); }
|
||||||
}
|
}
|
||||||
|
// Never trust the caller's PATH: helpers are found only in system directories.
|
||||||
|
::setenv("PATH", std::string(spec::kContainerPath).c_str(), 1);
|
||||||
std::vector<char*> a;
|
std::vector<char*> a;
|
||||||
for (const auto& x : argv) a.push_back(const_cast<char*>(x.c_str()));
|
for (const auto& x : argv) a.push_back(const_cast<char*>(x.c_str()));
|
||||||
a.push_back(nullptr);
|
a.push_back(nullptr);
|
||||||
@@ -146,6 +158,123 @@ ProcResult runProcess(const std::vector<std::string>& argv, bool capture, bool q
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string fetchText(const std::string& url) {
|
||||||
|
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
|
||||||
|
"--proto", "=https", "--max-time", "60", url},
|
||||||
|
true);
|
||||||
|
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||||
|
return r.out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchFile(const std::string& url, const fs::path& out, std::uint64_t maxBytes) {
|
||||||
|
ProcResult r = runProcess({resolveBinary("curl"), "--fail", "--silent", "--show-error", "--location",
|
||||||
|
"--proto", "=https", "--max-time", "60", "--max-filesize",
|
||||||
|
std::to_string(maxBytes), "--output", out.string(), url});
|
||||||
|
if (r.code != 0) throw std::runtime_error("download failed: " + url + " (curl exit " + std::to_string(r.code) + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
TempDir::TempDir() {
|
||||||
|
fs::path base;
|
||||||
|
if (const char* h = std::getenv("DOOS_HOME"); h && *h) base = h;
|
||||||
|
else if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) base = fs::path(x) / "doos";
|
||||||
|
else if (const char* home = std::getenv("HOME"); home && *home) base = fs::path(home) / ".local/share/doos";
|
||||||
|
else base = fs::temp_directory_path();
|
||||||
|
fs::path scratch = base / "tmp";
|
||||||
|
std::error_code ec;
|
||||||
|
fs::create_directories(scratch, ec);
|
||||||
|
if (ec) scratch = fs::temp_directory_path();
|
||||||
|
std::string tmpl = (scratch / "doos-XXXXXX").string();
|
||||||
|
if (!::mkdtemp(tmpl.data())) throw SysError("mkdtemp");
|
||||||
|
path_ = tmpl;
|
||||||
|
::chmod(path_.c_str(), 0700);
|
||||||
|
}
|
||||||
|
|
||||||
|
TempDir::~TempDir() {
|
||||||
|
std::error_code ec;
|
||||||
|
fs::remove_all(path_, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isHex64(const std::string& s) {
|
||||||
|
return s.size() == 64 && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isxdigit(c); });
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::string upperStr(std::string s) {
|
||||||
|
for (auto& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imports the publisher key from `keyUrl` into a fresh gpg homedir and checks its fingerprint is
|
||||||
|
// exactly `fpr`. Returns the gpg binary path, or "" (with a printed note) when gpg is not installed.
|
||||||
|
std::string importPinnedKey(const fs::path& homedir, const std::string& keyUrl, const std::string& fpr) {
|
||||||
|
std::string gpg;
|
||||||
|
try {
|
||||||
|
gpg = resolveBinary("gpg");
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
std::fprintf(stderr, "note: signature NOT checked (gpg is not installed); relying on https and the publisher's checksum\n");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
::chmod(homedir.c_str(), 0700);
|
||||||
|
fs::path keyFile = homedir / "key.asc";
|
||||||
|
fetchFile(keyUrl, keyFile);
|
||||||
|
std::string h = homedir.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(":::::::::" + upperStr(fpr) + ":") == std::string::npos) {
|
||||||
|
throw std::runtime_error("the key at " + keyUrl + " does not have the pinned fingerprint " + fpr);
|
||||||
|
}
|
||||||
|
return gpg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared GOODSIG/VALIDSIG parsing for both --verify forms (detached and clearsigned produce the
|
||||||
|
// same --status-fd protocol lines).
|
||||||
|
void requireGoodSignature(const ProcResult& v, const std::string& fpr, const std::string& what) {
|
||||||
|
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 " + what);
|
||||||
|
}
|
||||||
|
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(upperStr(t));
|
||||||
|
if (tok.size() > 2 && (tok[2] == upperStr(fpr) || tok.back() == upperStr(fpr))) valid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (v.code != 0 || !good || !valid) {
|
||||||
|
throw std::runtime_error("signature check failed for " + what + " (not signed by " + fpr + ")");
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "signature verified (key %s)\n", fpr.c_str());
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool verifyDetachedSignature(const fs::path& data, const fs::path& sig, const std::string& keyUrl, const std::string& fpr) {
|
||||||
|
TempDir home;
|
||||||
|
std::string gpg = importPinnedKey(home.path(), keyUrl, fpr);
|
||||||
|
if (gpg.empty()) return false;
|
||||||
|
ProcResult v = runProcess({gpg, "--batch", "--homedir", home.path().string(), "--status-fd", "1", "--verify",
|
||||||
|
sig.string(), data.string()},
|
||||||
|
true, true);
|
||||||
|
requireGoodSignature(v, fpr, data.filename().string());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool verifyClearsignedSignature(const fs::path& file, const std::string& keyUrl, const std::string& fpr) {
|
||||||
|
TempDir home;
|
||||||
|
std::string gpg = importPinnedKey(home.path(), keyUrl, fpr);
|
||||||
|
if (gpg.empty()) return false;
|
||||||
|
ProcResult v = runProcess({gpg, "--batch", "--homedir", home.path().string(), "--status-fd", "1", "--verify",
|
||||||
|
file.string()},
|
||||||
|
true, true);
|
||||||
|
requireGoodSignature(v, fpr, file.filename().string());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
namespace fsutil {
|
namespace fsutil {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -171,10 +300,11 @@ 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, const SkipFn& skip) {
|
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());
|
if (depth > spec::kMaxTreeDepth) throw std::runtime_error("tree too deep: " + from.string());
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
|
int dirfd = ::open(from.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||||
fs::directory_iterator it(from, ec), end;
|
fs::directory_iterator it(from, ec), end;
|
||||||
if (ec) { ++st.skipped; return; }
|
if (ec) { ++st.skipped; if (dirfd >= 0) ::close(dirfd); return; }
|
||||||
for (; it != end; it.increment(ec)) {
|
for (; it != end; it.increment(ec)) {
|
||||||
if (ec) { ++st.skipped; return; }
|
if (ec) { ++st.skipped; continue; }
|
||||||
struct stat sb;
|
struct stat sb;
|
||||||
const fs::path& src = it->path();
|
const fs::path& src = it->path();
|
||||||
fs::path dst = to / src.filename();
|
fs::path dst = to / src.filename();
|
||||||
@@ -188,9 +318,13 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
|
|||||||
++st.made;
|
++st.made;
|
||||||
copyRec(src, dst, st, depth + 1, skip);
|
copyRec(src, dst, st, depth + 1, skip);
|
||||||
} else if (S_ISLNK(sb.st_mode)) {
|
} else if (S_ISLNK(sb.st_mode)) {
|
||||||
|
char buf[4096];
|
||||||
|
ssize_t n = (dirfd >= 0) ? ::readlinkat(dirfd, src.filename().c_str(), buf, sizeof buf - 1) : -1;
|
||||||
|
if (n < 0) { ++st.skipped; continue; }
|
||||||
|
buf[n] = '\0';
|
||||||
std::error_code e2;
|
std::error_code e2;
|
||||||
fs::remove(dst, e2);
|
fs::remove(dst, e2);
|
||||||
fs::create_symlink(fs::read_symlink(src), dst);
|
fs::create_symlink(std::string(buf, static_cast<std::size_t>(n)), dst);
|
||||||
++st.made;
|
++st.made;
|
||||||
} else if (S_ISREG(sb.st_mode)) {
|
} else if (S_ISREG(sb.st_mode)) {
|
||||||
if (::access(src.c_str(), R_OK) != 0) { ++st.skipped; continue; }
|
if (::access(src.c_str(), R_OK) != 0) { ++st.skipped; continue; }
|
||||||
@@ -205,6 +339,7 @@ void copyRec(const fs::path& from, const fs::path& to, SeedStats& st, std::size_
|
|||||||
++st.skipped;
|
++st.skipped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (dirfd >= 0) ::close(dirfd);
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InodeHash {
|
struct InodeHash {
|
||||||
@@ -262,9 +397,16 @@ SeedStats copyEntries(const fs::path& from, const fs::path& to, const std::vecto
|
|||||||
SeedStats s = copyTree(src, dst, skip);
|
SeedStats s = copyTree(src, dst, skip);
|
||||||
total.made += s.made; total.skipped += s.skipped; total.bytes += s.bytes;
|
total.made += s.made; total.skipped += s.skipped; total.bytes += s.bytes;
|
||||||
} else if (S_ISLNK(sb.st_mode)) {
|
} else if (S_ISLNK(sb.st_mode)) {
|
||||||
|
fs::path parent = src.parent_path();
|
||||||
|
int pfd = ::open(parent.c_str(), O_PATH | O_DIRECTORY | O_CLOEXEC);
|
||||||
|
char buf[4096];
|
||||||
|
ssize_t n = (pfd >= 0) ? ::readlinkat(pfd, src.filename().c_str(), buf, sizeof buf - 1) : -1;
|
||||||
|
if (pfd >= 0) ::close(pfd);
|
||||||
|
if (n < 0) { ++total.skipped; continue; }
|
||||||
|
buf[n] = '\0';
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
fs::remove(dst, ec);
|
fs::remove(dst, ec);
|
||||||
fs::create_symlink(fs::read_symlink(src), dst);
|
fs::create_symlink(std::string(buf, static_cast<std::size_t>(n)), dst);
|
||||||
++total.made;
|
++total.made;
|
||||||
} else if (S_ISREG(sb.st_mode) && ::access(src.c_str(), R_OK) == 0) {
|
} else if (S_ISREG(sb.st_mode) && ::access(src.c_str(), R_OK) == 0) {
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
@@ -312,9 +454,29 @@ std::uint64_t diskUsage(const fs::path& p, std::size_t& skipped) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void mkdirs(const fs::path& p, mode_t mode) {
|
void mkdirs(const fs::path& p, mode_t mode) {
|
||||||
std::error_code ec;
|
if (p.empty()) return;
|
||||||
fs::create_directories(p, ec);
|
std::vector<std::string> comps;
|
||||||
if (ec) throw std::runtime_error("mkdir " + p.string() + ": " + ec.message());
|
for (const auto& part : p) {
|
||||||
|
std::string s = part.string();
|
||||||
|
if (s.empty() || s == ".") continue;
|
||||||
|
if (s == "..") throw std::runtime_error("mkdir " + p.string() + ": '..' not allowed");
|
||||||
|
comps.push_back(s);
|
||||||
|
}
|
||||||
|
if (comps.empty()) return;
|
||||||
|
bool absolute = p.is_absolute();
|
||||||
|
int base = ::open(absolute ? "/" : ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||||
|
if (base < 0) throw SysError("mkdir " + p.string());
|
||||||
|
Fd guard(base);
|
||||||
|
for (std::size_t i = 0; i < comps.size(); ++i) {
|
||||||
|
bool last = (i + 1 == comps.size());
|
||||||
|
mode_t m = last ? mode : 0755;
|
||||||
|
if (::mkdirat(guard.get(), comps[i].c_str(), m) != 0 && errno != EEXIST) {
|
||||||
|
throw SysError("mkdir " + p.string());
|
||||||
|
}
|
||||||
|
int nfd = ::openat(guard.get(), comps[i].c_str(), O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||||
|
if (nfd < 0) throw SysError("mkdir " + p.string() + " (not a directory or symlink)");
|
||||||
|
guard.reset(nfd);
|
||||||
|
}
|
||||||
::chmod(p.c_str(), mode);
|
::chmod(p.c_str(), mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,4 +520,27 @@ std::size_t countEntries(const fs::path& p, std::size_t cap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} // namespace fsutil
|
} // namespace fsutil
|
||||||
|
|
||||||
|
std::string resolveBinary(const std::string& name) {
|
||||||
|
if (name.find('/') != std::string::npos) return name;
|
||||||
|
// The state dir's bin/ wins: it is the trusted configuration location (0700, like /etc) and lets tests
|
||||||
|
// or a user override a helper without touching system directories. Never the caller's PATH.
|
||||||
|
std::string base;
|
||||||
|
if (const char* h = ::getenv("DOOS_HOME"); h && *h) base = h;
|
||||||
|
else if (const char* x = ::getenv("XDG_DATA_HOME"); x && *x) base = std::string(x) + "/doos";
|
||||||
|
else if (const char* home = ::getenv("HOME"); home && *home) base = std::string(home) + "/.local/share/doos";
|
||||||
|
if (!base.empty()) {
|
||||||
|
std::string p = base + "/bin/" + name;
|
||||||
|
struct stat st;
|
||||||
|
if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p;
|
||||||
|
}
|
||||||
|
for (const char* dir : {"/usr/local/bin", "/usr/bin", "/usr/local/sbin", "/usr/sbin", "/bin", "/sbin"}) {
|
||||||
|
std::string p = std::string(dir) + "/" + name;
|
||||||
|
struct stat st;
|
||||||
|
if (::stat(p.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(p.c_str(), X_OK) == 0) return p;
|
||||||
|
}
|
||||||
|
throw std::runtime_error("required helper '" + name +
|
||||||
|
"' not found in the state dir's bin/, /usr/local/bin, /usr/bin, /usr/sbin, /bin or /sbin (install it)");
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace doos
|
} // namespace doos
|
||||||
|
|||||||
@@ -61,6 +61,35 @@ struct ProcResult {
|
|||||||
// fork+exec without a shell. Captured output is capped; stderr is inherited unless quiet.
|
// fork+exec without a shell. Captured output is capped; stderr is inherited unless quiet.
|
||||||
ProcResult runProcess(const std::vector<std::string>& argv, bool capture = false, bool quiet = false);
|
ProcResult runProcess(const std::vector<std::string>& argv, bool capture = false, bool quiet = false);
|
||||||
|
|
||||||
|
// https-only fetch via curl (resolveBinary("curl")); throws on a non-2xx result or a transfer error.
|
||||||
|
std::string fetchText(const std::string& url);
|
||||||
|
void fetchFile(const std::string& url, const std::filesystem::path& out, std::uint64_t maxBytes = 1 << 20);
|
||||||
|
|
||||||
|
// Private scratch directory under the state dir's tmp/ (falls back to the system temp dir),
|
||||||
|
// removed on scope exit. Used for anything downloaded and verified before it is trusted.
|
||||||
|
class TempDir {
|
||||||
|
public:
|
||||||
|
TempDir();
|
||||||
|
~TempDir();
|
||||||
|
TempDir(const TempDir&) = delete;
|
||||||
|
TempDir& operator=(const TempDir&) = delete;
|
||||||
|
const std::filesystem::path& path() const { return path_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::filesystem::path path_;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool isHex64(const std::string& s);
|
||||||
|
|
||||||
|
// Verifies a detached signature (sig is a separate file from data) against a publisher key fetched
|
||||||
|
// over https and accepted only when its fingerprint equals the pinned one. Returns false (and prints
|
||||||
|
// a note) when gpg is not installed; throws when the signature is bad, missing, or by another key.
|
||||||
|
bool verifyDetachedSignature(const std::filesystem::path& data, const std::filesystem::path& sig,
|
||||||
|
const std::string& keyUrl, const std::string& fpr);
|
||||||
|
// Same, for a clearsigned single file (hashes and signature combined in one file, e.g. rclone's
|
||||||
|
// SHA256SUMS release artifact).
|
||||||
|
bool verifyClearsignedSignature(const std::filesystem::path& file, const std::string& keyUrl, const std::string& fpr);
|
||||||
|
|
||||||
namespace fsutil {
|
namespace fsutil {
|
||||||
struct SeedStats {
|
struct SeedStats {
|
||||||
std::size_t made = 0;
|
std::size_t made = 0;
|
||||||
@@ -88,4 +117,8 @@ void removeTree(const std::filesystem::path& p, std::size_t depth = 0);
|
|||||||
std::size_t countEntries(const std::filesystem::path& p, std::size_t cap);
|
std::size_t countEntries(const std::filesystem::path& p, std::size_t cap);
|
||||||
} // namespace fsutil
|
} // namespace fsutil
|
||||||
|
|
||||||
|
// Resolves an external helper binary against a fixed set of trusted directories
|
||||||
|
// (never the caller's PATH), throwing when it is not found.
|
||||||
|
std::string resolveBinary(const std::string& name);
|
||||||
|
|
||||||
} // namespace doos
|
} // namespace doos
|
||||||
|
|||||||
+91
-9
@@ -9,7 +9,11 @@ ok() { pass=$((pass+1)); echo "ok $1"; }
|
|||||||
bad() { fail=$((fail+1)); echo "FAIL $1"; }
|
bad() { fail=$((fail+1)); echo "FAIL $1"; }
|
||||||
check() { local d=$1; shift; if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi; }
|
check() { local d=$1; shift; if "$@" >/dev/null 2>&1; then ok "$d"; else bad "$d"; fi; }
|
||||||
out() { "$DOOS" attach t -- sh -c "$1" 2>&1; }
|
out() { "$DOOS" attach t -- sh -c "$1" 2>&1; }
|
||||||
trap '"$DOOS" delete --yes t >/dev/null 2>&1; rm -rf "$DOOS_HOME"' EXIT
|
trap '
|
||||||
|
for id in $(ls "$DOOS_HOME/davmounts" 2>/dev/null); do "$DOOS" davmount umount --yes "$id" >/dev/null 2>&1; done
|
||||||
|
"$DOOS" delete --yes t >/dev/null 2>&1
|
||||||
|
rm -rf "$DOOS_HOME"
|
||||||
|
' EXIT
|
||||||
|
|
||||||
check "list empty" "$DOOS" list
|
check "list empty" "$DOOS" list
|
||||||
check "docs cross-linked, links resolve" "$(dirname "$0")/../scripts/check-docs.sh"
|
check "docs cross-linked, links resolve" "$(dirname "$0")/../scripts/check-docs.sh"
|
||||||
@@ -50,8 +54,8 @@ wait; sleep 0.5
|
|||||||
"$DOOS" get_or_create --net none n >/dev/null
|
"$DOOS" get_or_create --net none n >/dev/null
|
||||||
"$DOOS" attach n -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && bad "net none blocks" || ok "net none blocks"
|
"$DOOS" attach n -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && bad "net none blocks" || ok "net none blocks"
|
||||||
"$DOOS" delete --yes n >/dev/null
|
"$DOOS" delete --yes n >/dev/null
|
||||||
"$DOOS" get_or_create --net host h >/dev/null
|
"$DOOS" get_or_create --net host --i-understand h >/dev/null
|
||||||
"$DOOS" attach h -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && ok "net host works" || bad "net host works"
|
"$DOOS" attach h --i-understand -- bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' >/dev/null 2>&1 && ok "net host works" || bad "net host works"
|
||||||
"$DOOS" delete --yes h >/dev/null
|
"$DOOS" delete --yes h >/dev/null
|
||||||
|
|
||||||
# delete: dry-run by default
|
# delete: dry-run by default
|
||||||
@@ -71,6 +75,81 @@ outs() { "$DOOS" attach s -- sh -c "$1" 2>&1; }
|
|||||||
check "ids fixed at creation" bash -c "! '$DOOS' get_or_create --ids subuid s1"
|
check "ids fixed at creation" bash -c "! '$DOOS' get_or_create --ids subuid s1"
|
||||||
check "bad size rejected" bash -c "! '$DOOS' get_or_create --size 1x z1"
|
check "bad size rejected" bash -c "! '$DOOS' get_or_create --size 1x z1"
|
||||||
check "size below minimum rejected" bash -c "! '$DOOS' get_or_create --size 1m z2"
|
check "size below minimum rejected" bash -c "! '$DOOS' get_or_create --size 1m z2"
|
||||||
|
check "size overflow rejected, not silently wrapped" bash -c "! '$DOOS' get_or_create --size 16777217t z3"
|
||||||
|
|
||||||
|
# --mount: extra bind mounts
|
||||||
|
mntd=$(mktemp -d); echo host-data > "$mntd/f"
|
||||||
|
"$DOOS" get_or_create --mount "$mntd:/data" m >/dev/null
|
||||||
|
[ "$("$DOOS" attach m -- cat /data/f)" = "host-data" ] && ok "mount: readable inside" || bad "mount: readable inside"
|
||||||
|
"$DOOS" attach m -- sh -c 'echo from-container > /data/g'
|
||||||
|
[ "$(cat "$mntd/g" 2>/dev/null)" = "from-container" ] && ok "mount: writes land on host" || bad "mount: writes land on host"
|
||||||
|
"$DOOS" delete --yes m >/dev/null
|
||||||
|
"$DOOS" get_or_create --mount "$mntd:/data:ro" mro >/dev/null
|
||||||
|
"$DOOS" attach mro -- sh -c 'echo x > /data/should-fail' 2>/dev/null && bad "mount: ro rejects writes" || ok "mount: ro rejects writes"
|
||||||
|
"$DOOS" delete --yes mro >/dev/null
|
||||||
|
check "mount: missing source rejected" bash -c "'$DOOS' get_or_create --mount /no/such/dir:/data mbad >/dev/null && ! '$DOOS' attach mbad -- true"
|
||||||
|
"$DOOS" delete --yes mbad >/dev/null 2>&1
|
||||||
|
check "mount: too many rejected" bash -c "! '$DOOS' get_or_create $(for i in 1 2 3 4 5 6 7 8 9; do printf -- '--mount %s:/m%s ' "$mntd" "$i"; done) mtoomany"
|
||||||
|
rm -rf "$mntd"
|
||||||
|
|
||||||
|
# --mount/--davmount with a dav(s):// or http(s):// source: auto-installs a managed rclone and
|
||||||
|
# mounts a real WebDAV target. The target here is a disposable local server, run with its own
|
||||||
|
# throwaway rclone copy (not doos's managed one) so the two downloads stay independent.
|
||||||
|
echo "--- davmount (network)"
|
||||||
|
davd=$(mktemp -d); echo webdav-data > "$davd/f.txt"
|
||||||
|
davport=$(python3 -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.bind(('127.0.0.1',0));print(s.getsockname()[1]);s.close()" 2>/dev/null)
|
||||||
|
davtmp=$(mktemp -d)
|
||||||
|
davok=0
|
||||||
|
if [ -n "$davport" ] && curl -fsSL --proto '=https' https://downloads.rclone.org/v1.75.2/rclone-v1.75.2-linux-amd64.zip -o "$davtmp/rclone.zip" 2>/dev/null \
|
||||||
|
&& unzip -q -o "$davtmp/rclone.zip" -d "$davtmp/x" 2>/dev/null; then
|
||||||
|
davrclone="$davtmp/x/rclone-v1.75.2-linux-amd64/rclone"
|
||||||
|
chmod +x "$davrclone" 2>/dev/null
|
||||||
|
[ -x "$davrclone" ] && davok=1
|
||||||
|
fi
|
||||||
|
if [ "$davok" = 1 ]; then
|
||||||
|
"$davrclone" serve webdav "$davd" --addr "127.0.0.1:$davport" --user davuser --pass davpass123 --log-level=ERROR >/tmp/doos-davserve.$$ 2>&1 &
|
||||||
|
davserve=$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
davurl="http://davuser:davpass123@127.0.0.1:$davport/"
|
||||||
|
"$DOOS" get_or_create --mount "$davurl" /data dav1 >/dev/null
|
||||||
|
[ "$("$DOOS" attach dav1 -- cat /data/f.txt)" = "webdav-data" ] && ok "davmount: readable inside" || bad "davmount: readable inside"
|
||||||
|
"$DOOS" attach dav1 -- sh -c 'echo from-dav-container > /data/g.txt'
|
||||||
|
sleep 2
|
||||||
|
[ "$(cat "$davd/g.txt" 2>/dev/null)" = "from-dav-container" ] && ok "davmount: writes reach the webdav backend" || bad "davmount: writes reach the webdav backend"
|
||||||
|
"$DOOS" delete --yes dav1 >/dev/null
|
||||||
|
|
||||||
|
"$DOOS" get_or_create --mount "$davurl" /data dav2 >/dev/null
|
||||||
|
t0=$(date +%s%N); "$DOOS" attach dav2 -- true; t1=$(date +%s%N)
|
||||||
|
[ $(( (t1 - t0) / 1000000 )) -lt 3000 ] && ok "davmount: reused without remounting" || bad "davmount: reused without remounting"
|
||||||
|
"$DOOS" delete --yes dav2 >/dev/null
|
||||||
|
|
||||||
|
"$DOOS" get_or_create --davmount "http://127.0.0.1:$davport/" /data --dav-user davuser --dav-pass davpass123 dav3 >/dev/null
|
||||||
|
[ "$("$DOOS" attach dav3 -- cat /data/f.txt)" = "webdav-data" ] && ok "davmount: --davmount with --dav-user/--dav-pass" || bad "davmount: --davmount with --dav-user/--dav-pass"
|
||||||
|
"$DOOS" delete --yes dav3 >/dev/null
|
||||||
|
|
||||||
|
"$DOOS" get_or_create --mount "$davurl" /data:ro davro >/dev/null
|
||||||
|
"$DOOS" attach davro -- sh -c 'echo x > /data/should-fail' 2>/dev/null && bad "davmount: ro rejects writes" || ok "davmount: ro rejects writes"
|
||||||
|
"$DOOS" delete --yes davro >/dev/null
|
||||||
|
|
||||||
|
check "davmount: wrong password rejected with a clear error" bash -c "
|
||||||
|
'$DOOS' get_or_create --mount 'http://127.0.0.1:$davport/' /data --dav-user davuser --dav-pass wrongpass davbad >/dev/null
|
||||||
|
'$DOOS' attach davbad -- true 2>&1 | grep -q 'authentication failed'
|
||||||
|
"
|
||||||
|
"$DOOS" delete --yes davbad >/dev/null 2>&1
|
||||||
|
|
||||||
|
check "davmount: unknown vendor rejected" bash -c "! '$DOOS' get_or_create --mount 'http://127.0.0.1:$davport/' /data --dav-vendor notavendor davvendor"
|
||||||
|
|
||||||
|
"$DOOS" davmount list | grep -q "$davport" && ok "davmount list: shows an active target" || bad "davmount list: shows an active target"
|
||||||
|
david=$("$DOOS" davmount list | awk -v p="$davport" '$0 ~ p {print $1}' | head -1)
|
||||||
|
"$DOOS" davmount umount "$david" | grep -q "dry run" && ok "davmount umount: dry-run by default" || bad "davmount umount: dry-run by default"
|
||||||
|
"$DOOS" davmount umount --yes "$david" | grep -q unmounted && ok "davmount umount: --yes removes it" || bad "davmount umount: --yes removes it"
|
||||||
|
|
||||||
|
kill $davserve 2>/dev/null
|
||||||
|
else
|
||||||
|
echo "skip: davmount tests (no network or unzip for the test webdav server)"
|
||||||
|
fi
|
||||||
|
rm -rf "$davd" "$davtmp" /tmp/doos-davserve.$$ 2>/dev/null
|
||||||
"$DOOS" attach s -- 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 "host mode: apt install completes" || bad "host mode: apt install completes"
|
"$DOOS" attach s -- 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 "host mode: apt install completes" || bad "host mode: apt install completes"
|
||||||
[ ! -e /usr/games/cowsay ] && ok "host untouched by apt install" || bad "host untouched by apt install"
|
[ ! -e /usr/games/cowsay ] && ok "host untouched by apt install" || bad "host untouched by apt install"
|
||||||
"$DOOS" delete --yes s >/dev/null; "$DOOS" delete --yes s1 >/dev/null
|
"$DOOS" delete --yes s >/dev/null; "$DOOS" delete --yes s1 >/dev/null
|
||||||
@@ -150,9 +229,9 @@ h() { "$DOOS" attach lim -- python3 -c "$1" 2>&1; }
|
|||||||
"$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 '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 | 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" 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" get_or_create --hardening relaxed --i-understand 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"
|
"$DOOS" attach rx --i-understand -- 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"
|
check "hardening fixed at creation" bash -c "! '$DOOS' get_or_create --hardening relaxed --i-understand lim"
|
||||||
"$DOOS" delete --yes lim >/dev/null; "$DOOS" delete --yes rx >/dev/null
|
"$DOOS" delete --yes lim >/dev/null; "$DOOS" delete --yes rx >/dev/null
|
||||||
|
|
||||||
# ---- pasta network mode
|
# ---- pasta network mode
|
||||||
@@ -204,12 +283,15 @@ 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 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
|
"$DOOS" image delete --yes alpine >/dev/null
|
||||||
shim=$(mktemp -d)
|
shim=$(mktemp -d)
|
||||||
|
mkdir -p "$DOOS_HOME/bin"
|
||||||
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
|
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"
|
rm -f "$DOOS_HOME/bin/gpg"; cp $shim/gpg "$DOOS_HOME/bin/gpg"
|
||||||
|
"$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"
|
[ ! -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
|
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"
|
cp $shim/gpg "$DOOS_HOME/bin/gpg"
|
||||||
rm -rf $shim
|
"$DOOS" image add alpine >/dev/null 2>&1 && bad "BADSIG refused" || ok "BADSIG refused"
|
||||||
|
rm -rf $shim; rm -f "$DOOS_HOME/bin/gpg"
|
||||||
"$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" 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
|
"$DOOS" delete --yes d1 >/dev/null; "$DOOS" image delete --yes debian >/dev/null
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# valgrind
|
||||||
|
|
||||||
|
**Scope:** [README](../README.md) · [AGENTS](../AGENTS.md) · [docs/RESEARCH](../docs/RESEARCH.md)
|
||||||
|
|
||||||
|
Memcheck run against a fixed, named set of `doos` invocations, to catch real memory bugs
|
||||||
|
(invalid reads/writes, use-after-free, definite leaks) in doos's own code: namespace setup,
|
||||||
|
mount handling, the CLI, the image store. Separate from `audit/` (named CVEs against other
|
||||||
|
software) and from `tests/run.sh` (behavior, not memory safety).
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```
|
||||||
|
make valgrind # builds build/doos first, then runs scripts/valgrind.sh against it
|
||||||
|
scripts/valgrind.sh PATH # run directly against a specific binary, e.g. a copy with an
|
||||||
|
# installed AppArmor profile (see README's apparmor section)
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports land in `build/valgrind-reports/<step>.log`, one per invocation, kept (not deleted)
|
||||||
|
after the run so a failure can be inspected. Each step prints `ok` or `FAIL`; the run exits
|
||||||
|
non-zero only if valgrind itself flagged something, never because `doos` exited non-zero for
|
||||||
|
its own reasons (an expected `delete` dry-run notice, an AppArmor block on a binary with no
|
||||||
|
profile installed, and so on, all exit non-zero as designed and are not memory bugs).
|
||||||
|
|
||||||
|
## What it checks
|
||||||
|
|
||||||
|
A fixed, capped list (`scripts/valgrind.sh`): `list`, `get_or_create`, `attach` (twice, to
|
||||||
|
exercise both the create path and joining an already-running container), `image list`,
|
||||||
|
`delete --yes`. All run with `--net none` and no image, so the check needs no network and no
|
||||||
|
download; it is about doos's own allocation code, not about what happens after it execs the
|
||||||
|
contained command.
|
||||||
|
|
||||||
|
## Suppressions (`doos.supp`)
|
||||||
|
|
||||||
|
Six specific, named, commented allocation sites, each captured with
|
||||||
|
`valgrind --gen-suppressions=all` against a real run and explained in the file itself: the
|
||||||
|
clone(2) child's 1 MiB stack buffer (lifetime tied to the child process, not a scope), and five
|
||||||
|
"an error path constructed an exception or opened a stream, then the namespace child exited
|
||||||
|
right after" patterns (`doMount`'s thrown `SysError`'s message string and the exception object
|
||||||
|
itself, once for a mount failure inside `setupRoot` and once for the mount that runs directly in
|
||||||
|
`containerInit` before it, such as an AppArmor-blocked `mount bind /`; plus an `ifstream` opened
|
||||||
|
in `containerInit`'s cold path). None of these are "definitely lost"; valgrind calls all of them
|
||||||
|
"possibly lost" because the process ends before the normal C++ unwind would run their
|
||||||
|
destructors, which is expected for a namespace child that is about to disappear regardless. A
|
||||||
|
genuinely new leak anywhere else is not covered by these exact stacks and still fails the check.
|
||||||
|
|
||||||
|
To add a suppression for a new, understood, one-time allocation: reproduce it, add
|
||||||
|
`--gen-suppressions=all` to the `valgrind` invocation in `scripts/valgrind.sh` temporarily, copy
|
||||||
|
the exact block `doos.supp` already prints into the file with a name and a comment explaining
|
||||||
|
why it is not a real leak, then remove the temporary flag. Never add a suppression you cannot
|
||||||
|
explain in one sentence.
|
||||||
|
|
||||||
|
## What this does not cover
|
||||||
|
|
||||||
|
- Anything that happens after doos execs the user's command: valgrind traces doos's own process
|
||||||
|
tree, not an arbitrary program run inside the container.
|
||||||
|
- Image download and extraction (`--image`): not in the fixed list, since it needs network and
|
||||||
|
adds `curl`/`tar`/`gpg` subprocess noise that is not doos's own memory. Add a step to
|
||||||
|
`scripts/valgrind.sh` following the same pattern if that code path needs a dedicated run.
|
||||||
|
- `slirp4netns`/`pasta`: separate processes, not doos; see `audit/CVE-2020-7039` for how their
|
||||||
|
memory safety is tracked instead (upstream version, not valgrind).
|
||||||
|
|
||||||
|
Back to [README](../README.md), [AGENTS](../AGENTS.md), [docs/RESEARCH](../docs/RESEARCH.md).
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Suppressions for `make valgrind` (see scripts/valgrind.sh and valgrind/README.md).
|
||||||
|
#
|
||||||
|
# Each entry here is a specific, named allocation site, not a blanket "ignore leaks" rule, and
|
||||||
|
# each one is explained. They were captured with `valgrind --gen-suppressions=all` against a
|
||||||
|
# real `doos attach` run and trimmed to the exact stack valgrind printed; a new, different leak
|
||||||
|
# anywhere else still fails the check. Regenerate a candidate block with
|
||||||
|
# `valgrind --gen-suppressions=all` if doos's internals change and one of these stops matching.
|
||||||
|
|
||||||
|
{
|
||||||
|
doos-clone-child-stack
|
||||||
|
# Sandbox::start allocates the 1 MiB stack clone(2) needs for the namespace child. Its
|
||||||
|
# lifetime is tied to that child process, not to a scope in the parent, so it is never
|
||||||
|
# `delete[]`d: freeing it after clone() returns would be safe only once the child has copied
|
||||||
|
# what it needs, and doing that bookkeeping would add a second code path purely to silence
|
||||||
|
# a tool, not to fix a real leak. Confirmed "possibly lost", never "definitely lost".
|
||||||
|
Memcheck:Leak
|
||||||
|
match-leak-kinds: possible
|
||||||
|
fun:_Znwm
|
||||||
|
fun:_ZN4doos7Sandbox5startERNS_8FileLockE
|
||||||
|
fun:_ZN4doos7Sandbox3runEv
|
||||||
|
fun:_ZN4doos3Cli9cmdAttachESt6vectorINSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEESaIS7_EE
|
||||||
|
fun:_ZN4doos3Cli3runEv
|
||||||
|
fun:main
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
doos-mount-error-exception-object
|
||||||
|
# doMount's cold (error) path throws a SysError built from a std::string; when the namespace
|
||||||
|
# child terminates right after printing that error, the in-flight exception object itself
|
||||||
|
# (allocated by __cxa_allocate_exception) has not yet been destroyed by the unwinder. This is
|
||||||
|
# the standard valgrind artifact for a C++ exception that is still alive when the process
|
||||||
|
# exits, not a missing `delete`.
|
||||||
|
Memcheck:Leak
|
||||||
|
match-leak-kinds: possible
|
||||||
|
fun:malloc
|
||||||
|
fun:__cxa_allocate_exception
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L9setupRootERKNS_9ContainerERKNSt10filesystem7__cxx114pathE
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||||
|
fun:clone
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
doos-mount-error-message-string
|
||||||
|
# Same doMount error path: the message string's internal buffer, built while constructing
|
||||||
|
# the SysError/runtime_error that is about to be thrown. Same explanation as
|
||||||
|
# doos-mount-error-exception-object, one frame earlier in the chain.
|
||||||
|
Memcheck:Leak
|
||||||
|
match-leak-kinds: possible
|
||||||
|
fun:_Znwm
|
||||||
|
fun:_ZNSs4_Rep9_S_createEmmRKSaIcE
|
||||||
|
fun:_ZNSt12__cow_stringC2EPKcm
|
||||||
|
fun:_ZNSt13runtime_errorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
|
||||||
|
fun:_ZN4doos8SysErrorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEi
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L9setupRootERKNS_9ContainerERKNSt10filesystem7__cxx114pathE
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||||
|
fun:clone
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
doos-mount-error-exception-object-top-level
|
||||||
|
# Same explanation as doos-mount-error-exception-object, for the mount call that runs
|
||||||
|
# directly in containerInit before setupRoot is reached (the very first "mount bind /",
|
||||||
|
# which is what fails when AppArmor blocks user namespaces for this binary's path).
|
||||||
|
Memcheck:Leak
|
||||||
|
match-leak-kinds: possible
|
||||||
|
fun:malloc
|
||||||
|
fun:__cxa_allocate_exception
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||||
|
fun:clone
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
doos-mount-error-message-string-top-level
|
||||||
|
# Same explanation as doos-mount-error-message-string, one level up: the mount call that
|
||||||
|
# runs directly in containerInit before setupRoot is reached.
|
||||||
|
Memcheck:Leak
|
||||||
|
match-leak-kinds: possible
|
||||||
|
fun:_Znwm
|
||||||
|
fun:_ZNSs4_Rep9_S_createEmmRKSaIcE
|
||||||
|
fun:_ZNSt12__cow_stringC2EPKcm
|
||||||
|
fun:_ZNSt13runtime_errorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
|
||||||
|
fun:_ZN4doos8SysErrorC1ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEi
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L7doMountEPKcRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES2_mSA_.isra.0.cold
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv
|
||||||
|
fun:clone
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
doos-containerinit-ifstream-buffer
|
||||||
|
# containerInit's cold (error) path opens an ifstream (a /proc file, for a diagnostic) whose
|
||||||
|
# libstdc++ filebuf buffer is still open when the namespace child exits right after. Same
|
||||||
|
# "process exited before an RAII object on an about-to-unwind error path ran its destructor"
|
||||||
|
# pattern as the two entries above, for a stream instead of an exception.
|
||||||
|
Memcheck:Leak
|
||||||
|
match-leak-kinds: possible
|
||||||
|
fun:_Znam
|
||||||
|
fun:_ZNSt13basic_filebufIcSt11char_traitsIcEE27_M_allocate_internal_bufferEv
|
||||||
|
fun:_ZNSt13basic_filebufIcSt11char_traitsIcEE4openEPKcSt13_Ios_Openmode
|
||||||
|
fun:_ZNSt14basic_ifstreamIcSt11char_traitsIcEEC1EPKcSt13_Ios_Openmode
|
||||||
|
fun:_ZN4doos12_GLOBAL__N_1L13containerInitEPv.cold
|
||||||
|
fun:clone
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user