Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Ignity supervises container processes through three discrete stages:
**Stage 2 (`/etc/s6/boot/stage2`):** Service & CMD Execution (depends on `IGNITY_MODE`)
- `stage2-copy`: Copies `/etc/ignity/{init,finalize,services}` to `/run/ignity`, only what the mode needs
- `stage2-init`: Executes the `/run/ignity/init/*` copies of `/etc/ignity/init/*` in order (skipped in `services` mode)
- `stage2-cmd`: Runs the CMD, dropping to `USERMAP_UID:USERMAP_GID` via `s6-applyuidgid` only if PID 1 is root and the mapping isn't 0:0 (supplementary groups set to the gid)
- `stage2-cmd`: Runs the CMD as PID 1 (root) by default. With `IGNITY_CMD_DROP_PRIVILEGES=1`, drops to `USERMAP_UID:USERMAP_GID` via `s6-applyuidgid` only if PID 1 is root and the mapping isn't 0:0 (supplementary groups set to the gid)
- `stage2-services`: Copies `/run/ignity/services/*` into the live scandir `/run/ignity/services-state` and starts them under s6 (skipped in `init` mode)
- Optionally runs CMD after services healthy (configurable delay via `IGNITY_CMD_WAIT_FOR_SERVICES`); skipped in `init` mode
- Writes the exit status to `/run/ignity/stage2-exited` for stage 3; a failure (or `init` mode completing) stops the supervision tree
Expand Down Expand Up @@ -141,6 +141,7 @@ Follow conventional commits: `type: description` (types: build, ci, docs, feat,
- `.github/workflows/ci.yml` — On PR/push (and reused by release): `poe test` (source), `package` per arch (native runners), `sanitize` runs the suite from the packages on debian/ubuntu/alpine/fedora

**Environment Variables (Runtime):**
- `IGNITY_CMD_DROP_PRIVILEGES` — Run the CMD as `USERMAP_UID:USERMAP_GID` instead of root (0/1, default 0)
- `IGNITY_CMD_WAIT_FOR_SERVICES` — Wait for services before CMD (0/1)
- `IGNITY_CMD_WAIT_FOR_SERVICES_MAXTIME` — Wait timeout in milliseconds (default 5000)
- `IGNITY_KILL_GRACETIME` — Shutdown grace period in ms (default 3000)
Expand All @@ -149,7 +150,7 @@ Follow conventional commits: `type: description` (types: build, ci, docs, feat,
- `IGNITY_SKIP_PERMS` — Skip permission fixing (0/1)
- `IGNITY_ACCOUNTS_DIR` — Write account files there instead of creating USER in /etc (read-only root filesystem, see `gen-accounts`)
- `IGNITY_MODE` — `full` (default) | `init` (init scripts only, then exit) | `services` (skip init scripts); for Kubernetes init containers. No env is handed between containers, share files through a volume
- `USER`, `USERMAP_UID`, `USERMAP_GID` — Non-root user mapping; also the uid/gid the CMD drops to (`stage2-cmd`) when PID 1 is root and the mapping isn't 0:0
- `USER`, `USERMAP_UID`, `USERMAP_GID` — Non-root user mapping; also the uid/gid the CMD drops to (`stage2-cmd`) with `IGNITY_CMD_DROP_PRIVILEGES=1`, when PID 1 is root and the mapping isn't 0:0

## Language & Implementation Details

Expand Down
29 changes: 19 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ IGNITY_INSTALL=package DOCKER_BASE_IMAGE=alpine:3 uv run poe test

| Parameter | Description | Value (Current) |
|----------------------------------------|-----------------------------------------------------------------------------------------------------------------------|-----------------|
| `IGNITY_CMD_DROP_PRIVILEGES` | Run the `CMD` as `USERMAP_UID:USERMAP_GID` instead of root (0/1, see "How to run the CMD as a non root user") | 0 |
| `IGNITY_CMD_WAIT_FOR_SERVICES_MAXTIME` | The maximum time (in milliseconds) the services could take to bring up before proceding to CMD executing | 5000 |
| `IGNITY_CMD_WAIT_FOR_SERVICES` | In order to proceed executing CMD overlay will wait until services are up | 0 |
| `IGNITY_KILL_FINALIZE_MAXTIME` | The maximum time (in milliseconds) a script in `/etc/ignity/finalize` could take before sending a `KILL` signal to it | 5000 |
Expand All @@ -125,8 +126,8 @@ IGNITY_INSTALL=package DOCKER_BASE_IMAGE=alpine:3 uv run poe test
| `IGNITY_SERVICES_GRACETIME` | How long (in milliseconds) `ignity` should wait services down before sending a down signal | 16000 |
| `IGNITY_SYNC_DISKS` | Call `s6-sync` before and after sending the termination signals (0/1) | 1 |
| `USER` | User name to map with uid and gid | root |
| `USERMAP_GID` | User gid to map on files, and group of the `CMD` (see "How to run the CMD as a non root user") | 0 |
| `USERMAP_UID` | User uid to map on files, and user of the `CMD` (see "How to run the CMD as a non root user") | 0 |
| `USERMAP_GID` | User gid to map on files, and group of the `CMD` with `IGNITY_CMD_DROP_PRIVILEGES=1` | 0 |
| `USERMAP_UID` | User uid to map on files, and user of the `CMD` with `IGNITY_CMD_DROP_PRIVILEGES=1` | 0 |

### How to integrate ignity in your project from a package

Expand Down Expand Up @@ -321,8 +322,8 @@ exec with-env -k 'HOME,LANG,TZ,APP_*' -d /run/secrets/myapp myapp
from getting it back. Services running with the same uid as PID 1 can read it.
- Dropping the uid does not clear the environment: a service still starts with the full container environment unless you
narrow it with `-k`.
- The `CMD` runs as `USERMAP_UID:USERMAP_GID` (see below), but it still starts with the full environment unless you
narrow it. Init and finalize scripts run with the uid of PID 1 (root), and the full environment, unless they drop
- The `CMD` runs as root, or as `USERMAP_UID:USERMAP_GID` with `IGNITY_CMD_DROP_PRIVILEGES=1` (see below), but it
still starts with the full environment unless you narrow it. Init and finalize scripts run with the uid of PID 1 (root), and the full environment, unless they drop
privileges themselves.

### How to use ignity with Kubernetes init containers
Expand Down Expand Up @@ -404,16 +405,17 @@ USER ${USERMAP_UID}:${USERMAP_GID}
| `CHOWN` | `fix-perms` changing owners |
| `FOWNER` | `fix-perms` changing modes of files that belong to another uid (the one it just handed over) |
| `DAC_OVERRIDE` | `fix-perms` reading what it just handed over (a `0700` directory of another uid) |
| `SETUID` | The `CMD` (`USERMAP_UID`) and services dropping privileges |
| `SETUID` | The `CMD` (`IGNITY_CMD_DROP_PRIVILEGES`) and services dropping privileges |
| `SETGID` | Same, including supplementary groups |
| `KILL` | Sending signals to processes running as another uid: stopping services, shutdown (without it the container never stops) |

- Nothing else is used: no `NET_RAW`, `NET_BIND_SERVICE`, `SYS_CHROOT`, `MKNOD`, `SETFCAP`, `SETPCAP`, `AUDIT_WRITE`...
Add `FSETID` only if your `perms` files set setuid or setgid bits.
- This list comes from reading the scripts and is checked by `tests/test_least_privilege.py`. It doesn't cover what your
own init scripts and services do.
- A process that drops privileges loses its capabilities (the `CMD` ends up with none), so they only concern PID 1, the
init and finalize scripts.
- A process that drops privileges loses its capabilities (the `CMD` ends up with none with
`IGNITY_CMD_DROP_PRIVILEGES=1`), so they only concern PID 1, the init and finalize scripts, and the `CMD` when it
runs as root.
- `allowPrivilegeEscalation: false` is fine: nothing relies on setuid binaries.

**Read-only root filesystem**
Expand Down Expand Up @@ -442,6 +444,7 @@ spec:
env:
- { name: USERMAP_UID, value: "1000" }
- { name: USERMAP_GID, value: "1000" }
- { name: IGNITY_CMD_DROP_PRIVILEGES, value: "1" }
securityContext:
runAsUser: 0
allowPrivilegeEscalation: false
Expand Down Expand Up @@ -503,6 +506,7 @@ spec:
- { name: USER, value: app }
- { name: USERMAP_UID, value: "1000" }
- { name: USERMAP_GID, value: "1000" }
- { name: IGNITY_CMD_DROP_PRIVILEGES, value: "1" }
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
Expand Down Expand Up @@ -544,9 +548,11 @@ spec:

### How to run the CMD as a non root user

- By default `USERMAP_UID` and `USERMAP_GID` are `0`, so the `CMD` runs as root, like PID 1.
- If PID 1 is root and either of them is not `0`, the `CMD` runs as `USERMAP_UID:USERMAP_GID`. Supplementary groups are
replaced by `USERMAP_GID`, the `CMD` doesn't keep the groups of root.
- By default the `CMD` runs as root, like PID 1, even when `USERMAP_UID` and `USERMAP_GID` are set (they are then only
used for `perms`): the `CMD` is mostly there to debug or test.
- Set `IGNITY_CMD_DROP_PRIVILEGES=1` to opt in. If PID 1 is root and either of `USERMAP_UID` and `USERMAP_GID` is not
`0`, the `CMD` then runs as `USERMAP_UID:USERMAP_GID`. Supplementary groups are replaced by `USERMAP_GID`, the `CMD`
doesn't keep the groups of root.
- Only the `CMD` is affected. Init scripts, finalize scripts and services keep running as PID 1, and services drop
privileges themselves in their `run` script if they need it.
- The environment is not changed: `HOME`, `USER` and the other variables stay as they are, so set them in the container
Expand All @@ -561,6 +567,7 @@ spec:
env:
- { name: USERMAP_UID, value: "1000" }
- { name: USERMAP_GID, value: "1000" }
- { name: IGNITY_CMD_DROP_PRIVILEGES, value: "1" }
```

### How to run a container in read-only mode
Expand Down Expand Up @@ -641,6 +648,8 @@ exec s6-setuidgid daemon myservice
It is possible somehow to tweak `ignity` behaviour by providing an already predefined set of environment variables to
the execution context:

- `IGNITY_CMD_DROP_PRIVILEGES` (default = 0): Run the `CMD` as `USERMAP_UID:USERMAP_GID` instead of root. See "How to
run the CMD as a non root user".
- `IGNITY_CMD_WAIT_FOR_SERVICES_MAXTIME` (default = 5000): The maximum time (in milliseconds) the services could take to
bring up before proceding to CMD executing.
- `IGNITY_CMD_WAIT_FOR_SERVICES` (default = 0): In order to proceed executing CMD overlay will wait until services are
Expand Down
8 changes: 6 additions & 2 deletions src/etc/s6/boot/stage2-cmd
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#!/usr/lib/ignity/bin/execlineb -S0

# Run the CMD, dropping privileges to USERMAP_UID:USERMAP_GID when it makes sense:
# - they are both 0 (default): nothing to drop, the CMD runs as root like before
# Run the CMD, as PID 1 (root) unless IGNITY_CMD_DROP_PRIVILEGES=1.
# When opted in, it drops privileges to USERMAP_UID:USERMAP_GID when it makes sense:
# - they are both 0 (default): nothing to drop, the CMD runs as root
# - PID 1 is not root (e.g. USER set in the Dockerfile): it can't change uid, the CMD runs as is
importas -D 0 IGNITY_CMD_DROP_PRIVILEGES IGNITY_CMD_DROP_PRIVILEGES
ifelse { eltest ${IGNITY_CMD_DROP_PRIVILEGES} -ne 1 } { $@ }

importas -D 0 USERMAP_UID USERMAP_UID
importas -D 0 USERMAP_GID USERMAP_GID

Expand Down
6 changes: 5 additions & 1 deletion src/usr/bin/gen-accounts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ if {
exit 1
}
if {
ifelse { eltest ${USERMAP_UID} -ge 0 -a ${USERMAP_GID} -ge 0 } { exit 0 }
# eltest -ge accepts values like 1000:0, check the digits instead
ifelse {
if { pipeline { s6-echo -n -- ${USERMAP_UID} } grep -Eqzx "[0-9]+" }
pipeline { s6-echo -n -- ${USERMAP_GID} } grep -Eqzx "[0-9]+"
} { exit 0 }
fdmove -c 1 2
foreground { s6-echo -- "[supervisor/accounts] USERMAP_UID and USERMAP_GID must be numbers" }
exit 1
Expand Down
17 changes: 15 additions & 2 deletions tests/test_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,21 @@ def test_cmd_runs_as_root_by_default(image):
assert "UID=0 GID=0" in result.stdout


def test_cmd_stays_root_with_usermap_by_default(image):
result = run_in_container(
image,
"echo UID=$(id -u) GID=$(id -g)",
extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000),
)
assert result.returncode == 0
assert "UID=0 GID=0" in result.stdout


def test_cmd_drops_privileges_to_usermap(image):
result = run_in_container(
image,
"echo UID=$(id -u) GID=$(id -g) GROUPS=$(id -G)",
extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000),
extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000, IGNITY_CMD_DROP_PRIVILEGES=1),
)
assert result.returncode == 0
assert "UID=1000 GID=1000 GROUPS=1000" in result.stdout
Expand All @@ -61,6 +71,7 @@ def test_cmd_dropped_cannot_read_pid1_environ(image):
"cat /proc/1/environ >/dev/null 2>&1; echo RC=$?",
extra_args=[
"-e", "USERMAP_UID=1000", "-e", "USERMAP_GID=1000", "-e", "MYSECRET=x",
"-e", "IGNITY_CMD_DROP_PRIVILEGES=1",
],
)
assert "RC=0" not in result.stdout
Expand All @@ -71,7 +82,7 @@ def test_cmd_exit_code_kept_when_dropping_privileges(image):
result = run_in_container(
image,
"exit 7",
extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000),
extra_args=env_args(USERMAP_UID=1000, USERMAP_GID=1000, IGNITY_CMD_DROP_PRIVILEGES=1),
)
assert result.returncode == 7

Expand Down Expand Up @@ -115,6 +126,7 @@ def test_accounts_of_init_container_mounted_in_main_container(image, tmp_path):
*READ_ONLY,
"-v", f"{tmp_path}/passwd:/etc/passwd:ro",
"-v", f"{tmp_path}/group:/etc/group:ro",
"-e", "IGNITY_CMD_DROP_PRIVILEGES=1",
*_ACCOUNT,
],
)
Expand Down Expand Up @@ -159,6 +171,7 @@ def test_accounts_reject_non_numeric_ids(image):
],
)
assert result.returncode != 0
assert "must be numbers" in result.stdout + result.stderr


def test_accounts_never_duplicate_an_existing_id(image):
Expand Down
10 changes: 7 additions & 3 deletions tests/test_least_privilege.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

# What ignity needs when PID 1 is root:
# - CHOWN, FOWNER, DAC_OVERRIDE: fix-perms (chown, chmod, then traverse what it just handed over)
# - SETUID, SETGID: dropping privileges (CMD with USERMAP_*, services)
# - SETUID, SETGID: dropping privileges (CMD with IGNITY_CMD_DROP_PRIVILEGES, services)
# - KILL: signalling processes running as another uid (stop services, shutdown)
MINIMAL = (
"--cap-drop=ALL",
Expand All @@ -24,7 +24,9 @@
*READ_ONLY,
# Written by the init script of the fixture
"--tmpfs=/shared",
*env_args(USERMAP_UID=1000, USERMAP_GID=1000, IGNITY_SERVICES_GRACETIME=3000),
*env_args(
USERMAP_UID=1000, USERMAP_GID=1000, IGNITY_CMD_DROP_PRIVILEGES=1, IGNITY_SERVICES_GRACETIME=3000,
),
)


Expand Down Expand Up @@ -80,7 +82,9 @@ def _args(without: str | None = None) -> tuple[str, ...]:
*caps,
"--security-opt=no-new-privileges",
# Not read-only: /data is part of the image and has to be fixed by fix-perms
*env_args(USERMAP_UID=1234, USERMAP_GID=1234, IGNITY_SERVICES_GRACETIME=3000),
*env_args(
USERMAP_UID=1234, USERMAP_GID=1234, IGNITY_CMD_DROP_PRIVILEGES=1, IGNITY_SERVICES_GRACETIME=3000,
),
)


Expand Down
Loading