From 1e7d989eac4fc2b774ddb706cf9486c3b46f7611 Mon Sep 17 00:00:00 2001 From: DC Date: Wed, 17 Jun 2026 11:57:10 -0600 Subject: [PATCH 1/4] fix: make Zallet config readable by the container uid (1000) Zallet runs on a distroless image pinned to user "1000:1000" (no shell, entrypoint, or capabilities to chown/override at runtime). It reads two bind-mounted host files, zallet.toml and zallet_identity.txt, whose host ownership/mode pass straight through. Operators whose host uid \!= 1000 hit: zallet fatal error: config error: path error: /etc/zallet/zallet.toml: Permission denied (os error 13) because both files end up mode 0600 owned by the host uid: setup-network.sh chmod 600s the age key, and regtest-init.sh rewrites zallet.toml via mktemp+mv, leaking mktemp's 0600 onto the config. Decouple host uid from container uid the same way the cookie-permissions sidecar already does for Zebra's RPC cookie, rather than parametrizing the pinned uid (which would force operators to also solve data-volume ownership that distroless Zallet cannot self-heal): - setup-network.sh: chmod 644 the copied non-secret TOMLs; grant the age key read to uid 1000 only via `setfacl -m u:1000:r` (stays 0600 otherwise), with a warning + chmod 644 fallback when setfacl/acl is unavailable. - regtest-init.sh: chmod 644 zallet.toml after the pwhash rewrite. - docs + README: document that uid \!= 1000 operators need no uid coordination for Zallet config; note the acl/setfacl soft prerequisite. - ci.yaml: reproduce the scripts' permission end-state in regtest staging and assert a --user 1000:1000 container can read both config files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yaml | 15 +++++++++++++++ README.md | 2 ++ docs/docker-architecture.md | 6 ++++++ scripts/regtest-init.sh | 3 +++ scripts/setup-network.sh | 13 +++++++++++++ 5 files changed, 39 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5c85560..68e234c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -85,6 +85,12 @@ jobs: SALT=$(openssl rand -hex 16) HASH=$(printf 'zebra' | openssl dgst -sha256 -mac HMAC -macopt "key:$SALT" | awk '{print $NF}') sed -i "s|__GENERATED_BY_INIT_SH__|${SALT}\$${HASH}|" config/regtest/zallet.toml + # Mirror the permission end-state of setup-network.sh / regtest-init.sh + # so the readability assertion below exercises the real contract: + # non-secret TOMLs world-readable, the age key 0600 + ACL for uid 1000. + chmod 644 config/regtest/zallet.toml config/regtest/zaino.toml config/regtest/zebra.toml + chmod 600 config/regtest/zallet_identity.txt + setfacl -m u:1000:r config/regtest/zallet_identity.txt - name: Start Zebra (regtest) run: | @@ -136,6 +142,15 @@ jobs: - name: Verify Zallet accepts compose command run: docker compose --env-file .env.regtest run --rm --no-deps zallet --help + - name: Assert Zallet config is readable by the container uid (1000) + # Zallet runs as uid 1000 (distroless image, no runtime chown). Guards + # the config-readability contract: zallet.toml is world-readable and the + # age key is reachable by uid 1000 via ACL. Catches a regression of the + # mktemp-0600 mode leaking back onto the rewritten config. + run: | + docker run --rm --user 1000:1000 -v "$PWD/config/regtest":/c:ro alpine \ + sh -c 'cat /c/zallet.toml >/dev/null && cat /c/zallet_identity.txt >/dev/null && echo "uid 1000 read OK"' + - name: Verify the cookie volume is attachable # Regtest disables cookie auth (ZEBRA_RPC__ENABLE_COOKIE_AUTH=false) so # Zebra does not write /auth/.cookie. This check only verifies that the diff --git a/README.md b/README.md index c26bd4e..e10ae0a 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,8 @@ Fix permissions before starting: Zebra, Zaino, and Zallet each run as a specific non-root user. Directories must have correct ownership (set by the script) and `700` permissions. Never use `755` or `777`. +Operators whose host uid is not `1000` do **not** need to coordinate uids for Zallet's bind-mounted config. Zallet runs as uid 1000 (distroless image), and `setup-network.sh` makes `zallet.toml` readable by that uid (`0644`) and grants the age key `zallet_identity.txt` read access for uid 1000 via a POSIX ACL (`setfacl -m u:1000:r`) while keeping it `0600` for everyone else. Install the `acl` package on Linux for this; if `setfacl` is unavailable the script warns and you can fall back to `chmod 644` on the identity file. +
diff --git a/docs/docker-architecture.md b/docs/docker-architecture.md index eb0c982..d99073f 100644 --- a/docs/docker-architecture.md +++ b/docs/docker-architecture.md @@ -268,6 +268,12 @@ The base compose includes a small `cookie-permissions` sidecar (`alpine:3` with Zaino and Zallet depend on the sidecar's healthcheck, so targeted starts such as `docker compose up -d zaino` also start the sidecar and wait until the cookie is readable. On mainnet and testnet the healthcheck waits for `/var/run/auth/.cookie`; on regtest it exits successfully because cookie auth is disabled and username/password auth is used instead. +## Zallet config readability and operator uid + +Zallet uses a distroless image with no shell, entrypoint script, or Linux capabilities, so the base compose pins it with `user: "1000:1000"` and it runs as that uid from PID 1 — unlike Zebra (root entrypoint that drops to uid 10001) and Zaino (root entrypoint with `cap_add: [DAC_OVERRIDE, ...]`, which bypasses file-permission checks before dropping privileges). Zallet therefore can only read its two bind-mounted host config files — `config//zallet.toml` → `/etc/zallet/zallet.toml` and `config//zallet_identity.txt` → `/etc/zallet/identity.txt` — if they are readable by uid 1000. + +The same "no UID coordination required" property that the cookie sidecar provides applies here: the setup scripts make this config readable by uid 1000 regardless of the operator's host uid. `scripts/setup-network.sh` writes `zallet.toml` (and the other non-secret TOMLs) mode `0644`; `scripts/regtest-init.sh` restores `0644` after its `mktemp`+`mv` pwhash rewrite (which would otherwise leave the file `0600`). The age key `zallet_identity.txt` is a long-lived wallet secret, so instead of widening it to all local users it stays mode `0600` with a POSIX ACL granting read to uid 1000 only (`setfacl -m u:1000:r`). `setfacl` (the `acl` package on Linux, with an ACL-capable filesystem such as ext4/xfs) is a soft prerequisite; if it is unavailable the script warns and the operator can fall back to `chmod 644` on the identity file. + ## Regtest overlay constraints ### Zaino authentication diff --git a/scripts/regtest-init.sh b/scripts/regtest-init.sh index 15d8d58..0df7f8d 100755 --- a/scripts/regtest-init.sh +++ b/scripts/regtest-init.sh @@ -107,6 +107,9 @@ update_zallet_rpc_pwhash() { sed -E "s|^pwhash = \".*\"$|pwhash = \"${pwhash}\"|" "$config_path" > "$tmp" mv "$tmp" "$config_path" + # mktemp creates the temp file mode 0600 and mv carries that mode onto the + # config; restore world-read so the zallet container (uid 1000) can read it. + chmod 644 "$config_path" log "==> Generated zallet RPC pwhash in ${CONFIG_DIR}/zallet.toml" } diff --git a/scripts/setup-network.sh b/scripts/setup-network.sh index 9e8b9d5..81aa45f 100755 --- a/scripts/setup-network.sh +++ b/scripts/setup-network.sh @@ -48,6 +48,10 @@ copy_template() { fi cp "$example" "$active" + # Make the config world-readable so the pinned zallet container uid (1000) + # can read it regardless of the operator's host uid/umask. These TOMLs are + # not secret; the age key is handled separately in ensure_identity. + chmod 644 "$active" log "==> $NETWORK/$file: created from .example template." } @@ -67,6 +71,15 @@ ensure_identity() { rage-keygen -o "$identity" chmod 600 "$identity" + # Zallet runs as uid 1000 (distroless image, no runtime chown). Grant that + # uid read access to the age key without widening it to other host users. + if command -v setfacl >/dev/null 2>&1; then + setfacl -m u:1000:r "$identity" \ + || log "WARN: setfacl failed on $identity; zallet (uid 1000) may not be able to read it." + else + log "WARN: setfacl not found. Grant uid 1000 read on $identity before starting zallet" + log " (install the 'acl' package, or 'chmod 644 $identity' to allow all local users)." + fi log "==> $NETWORK/zallet_identity.txt: generated." } From 3398c617d78cafb4ea190ac60d1bc31df79f04e7 Mon Sep 17 00:00:00 2001 From: DC Date: Wed, 17 Jun 2026 15:09:13 -0600 Subject: [PATCH 2/4] fix: fail-fast on missing ACL support and install acl in CI Address Copilot review feedback on the Zallet config-readability change: - setup-network.sh: skip setfacl when the host uid is already 1000 (the 0600 age key is readable as-is) and fail fast with actionable instructions when uid != 1000 and the ACL cannot be applied (setfacl missing or failing), instead of warning and letting Zallet crash at startup. The age key is never auto-widened to 0644. - ci.yaml: install the acl package before applying the ACL so the uid-1000 readability assertion does not depend on the runner image. - README.md / docs/docker-architecture.md: document the uid-1000 skip and the new fail-fast behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yaml | 3 +++ README.md | 2 +- docs/docker-architecture.md | 2 +- scripts/setup-network.sh | 22 ++++++++++++++-------- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 68e234c..44c19ec 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,6 +88,9 @@ jobs: # Mirror the permission end-state of setup-network.sh / regtest-init.sh # so the readability assertion below exercises the real contract: # non-secret TOMLs world-readable, the age key 0600 + ACL for uid 1000. + # Install acl explicitly so the setfacl call (and the ACL the uid-1000 + # readability assertion depends on) does not rely on the runner image. + sudo apt-get update -qq && sudo apt-get install -y -qq acl chmod 644 config/regtest/zallet.toml config/regtest/zaino.toml config/regtest/zebra.toml chmod 600 config/regtest/zallet_identity.txt setfacl -m u:1000:r config/regtest/zallet_identity.txt diff --git a/README.md b/README.md index e10ae0a..dbdcbdd 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ Fix permissions before starting: Zebra, Zaino, and Zallet each run as a specific non-root user. Directories must have correct ownership (set by the script) and `700` permissions. Never use `755` or `777`. -Operators whose host uid is not `1000` do **not** need to coordinate uids for Zallet's bind-mounted config. Zallet runs as uid 1000 (distroless image), and `setup-network.sh` makes `zallet.toml` readable by that uid (`0644`) and grants the age key `zallet_identity.txt` read access for uid 1000 via a POSIX ACL (`setfacl -m u:1000:r`) while keeping it `0600` for everyone else. Install the `acl` package on Linux for this; if `setfacl` is unavailable the script warns and you can fall back to `chmod 644` on the identity file. +Operators whose host uid is not `1000` do **not** need to coordinate uids for Zallet's bind-mounted config. Zallet runs as uid 1000 (distroless image), and `setup-network.sh` makes `zallet.toml` readable by that uid (`0644`) and grants the age key `zallet_identity.txt` read access for uid 1000 via a POSIX ACL (`setfacl -m u:1000:r`) while keeping it `0600` for everyone else. When your host uid is already `1000` the `0600` key is readable as-is and no ACL is needed; otherwise install the `acl` package on Linux. If your host uid is not `1000` and `setfacl` is unavailable, `setup-network.sh` stops with instructions rather than leaving Zallet to crash at startup — install `acl` and run `setfacl -m u:1000:r` on the identity file (or `chmod 644` it to allow all local users).
diff --git a/docs/docker-architecture.md b/docs/docker-architecture.md index d99073f..2092eb8 100644 --- a/docs/docker-architecture.md +++ b/docs/docker-architecture.md @@ -272,7 +272,7 @@ Zaino and Zallet depend on the sidecar's healthcheck, so targeted starts such as Zallet uses a distroless image with no shell, entrypoint script, or Linux capabilities, so the base compose pins it with `user: "1000:1000"` and it runs as that uid from PID 1 — unlike Zebra (root entrypoint that drops to uid 10001) and Zaino (root entrypoint with `cap_add: [DAC_OVERRIDE, ...]`, which bypasses file-permission checks before dropping privileges). Zallet therefore can only read its two bind-mounted host config files — `config//zallet.toml` → `/etc/zallet/zallet.toml` and `config//zallet_identity.txt` → `/etc/zallet/identity.txt` — if they are readable by uid 1000. -The same "no UID coordination required" property that the cookie sidecar provides applies here: the setup scripts make this config readable by uid 1000 regardless of the operator's host uid. `scripts/setup-network.sh` writes `zallet.toml` (and the other non-secret TOMLs) mode `0644`; `scripts/regtest-init.sh` restores `0644` after its `mktemp`+`mv` pwhash rewrite (which would otherwise leave the file `0600`). The age key `zallet_identity.txt` is a long-lived wallet secret, so instead of widening it to all local users it stays mode `0600` with a POSIX ACL granting read to uid 1000 only (`setfacl -m u:1000:r`). `setfacl` (the `acl` package on Linux, with an ACL-capable filesystem such as ext4/xfs) is a soft prerequisite; if it is unavailable the script warns and the operator can fall back to `chmod 644` on the identity file. +The same "no UID coordination required" property that the cookie sidecar provides applies here: the setup scripts make this config readable by uid 1000 regardless of the operator's host uid. `scripts/setup-network.sh` writes `zallet.toml` (and the other non-secret TOMLs) mode `0644`; `scripts/regtest-init.sh` restores `0644` after its `mktemp`+`mv` pwhash rewrite (which would otherwise leave the file `0600`). The age key `zallet_identity.txt` is a long-lived wallet secret, so instead of widening it to all local users it stays mode `0600` with a POSIX ACL granting read to uid 1000 only (`setfacl -m u:1000:r`). The ACL only matters when the operator's host uid is not 1000 — when it is 1000 the `0600` key is already owned by, and readable as, uid 1000, so `setup-network.sh` skips the ACL. When the host uid is not 1000, `setfacl` (the `acl` package on Linux, with an ACL-capable filesystem such as ext4/xfs) is required: if it is unavailable or fails, the script stops with instructions rather than letting Zallet crash at startup, and the operator installs `acl` (or falls back to `chmod 644` on the identity file to allow all local users). ## Regtest overlay constraints diff --git a/scripts/setup-network.sh b/scripts/setup-network.sh index 81aa45f..4a335ff 100755 --- a/scripts/setup-network.sh +++ b/scripts/setup-network.sh @@ -71,14 +71,20 @@ ensure_identity() { rage-keygen -o "$identity" chmod 600 "$identity" - # Zallet runs as uid 1000 (distroless image, no runtime chown). Grant that - # uid read access to the age key without widening it to other host users. - if command -v setfacl >/dev/null 2>&1; then - setfacl -m u:1000:r "$identity" \ - || log "WARN: setfacl failed on $identity; zallet (uid 1000) may not be able to read it." - else - log "WARN: setfacl not found. Grant uid 1000 read on $identity before starting zallet" - log " (install the 'acl' package, or 'chmod 644 $identity' to allow all local users)." + # Zallet runs as uid 1000 (distroless image, no runtime chown), so it can + # only read the age key if uid 1000 has read access. When the operator's + # host uid is already 1000 the 0600 file is readable as-is; otherwise grant + # uid 1000 read via a POSIX ACL without widening the key to other users. + if [ "$(id -u)" -ne 1000 ]; then + if command -v setfacl >/dev/null 2>&1 && setfacl -m u:1000:r "$identity"; then + : # uid 1000 granted read via ACL + else + log "FAIL: cannot grant uid 1000 read on $identity (host uid $(id -u) != 1000)." >&2 + log " zallet (uid 1000) could not read the age key and would fail to start." >&2 + log " Install the 'acl' package and run: setfacl -m u:1000:r $identity" >&2 + log " (or 'chmod 644 $identity' to allow all local users)." >&2 + exit 1 + fi fi log "==> $NETWORK/zallet_identity.txt: generated." } From 4f117f08290693b4d42ab34f45a5e2be50d06298 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Fri, 19 Jun 2026 07:04:10 -0700 Subject: [PATCH 3/4] fix: validate zallet config permissions in CI --- .github/workflows/ci.yaml | 134 +++++++++++++++++++++++++++++------- README.md | 4 +- docs/docker-architecture.md | 6 +- scripts/regtest-init.sh | 42 +++++++++-- scripts/setup-network.sh | 74 +++++++++++++------- 5 files changed, 196 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 44c19ec..0b06e93 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,6 +59,91 @@ jobs: - name: Validate contract inventory parity (env vars across compose and .env.example) run: ./scripts/validate-contract-parity.py + zallet-config-permission-smoke: + name: Zallet config permission smoke + runs-on: ubuntu-latest + needs: contract-validation + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install ACL tooling + run: sudo apt-get update -qq && sudo apt-get install -y -qq acl + + - name: Provide fake rage-keygen + # This job validates z3's permission contract, not rage itself. + run: | + mkdir -p "$RUNNER_TEMP/fake-bin" + cat > "$RUNNER_TEMP/fake-bin/rage-keygen" <<'EOF' + #!/usr/bin/env sh + set -eu + if [ "$#" -ne 2 ] || [ "$1" != "-o" ]; then + echo "usage: rage-keygen -o " >&2 + exit 2 + fi + printf '%s\n' '# dummy identity for CI permission smoke' > "$2" + EOF + chmod +x "$RUNNER_TEMP/fake-bin/rage-keygen" + echo "$RUNNER_TEMP/fake-bin" >> "$GITHUB_PATH" + + - name: Fresh setup files are readable by Zallet's compose user + run: | + set -euo pipefail + umask 077 + ./scripts/setup-network.sh mainnet + + test "$(stat -c '%a' config/mainnet/zaino.toml)" = "644" + test "$(stat -c '%a' config/mainnet/zallet.toml)" = "644" + test "$(stat -c '%a' config/mainnet/zallet_identity.txt)" = "600" + getfacl -cpn config/mainnet/zallet_identity.txt | grep -q '^user:1000:r--$' + + ZALLET_USER="$(docker compose --env-file .env.mainnet config --format json \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["zallet"]["user"])')" + docker run --rm --user "$ZALLET_USER" -v "$PWD/config/mainnet":/c:ro alpine \ + sh -c 'cat /c/zallet.toml >/dev/null && cat /c/zallet_identity.txt >/dev/null' + + - name: Existing restrictive files are repaired by setup-network.sh + run: | + set -euo pipefail + umask 077 + ./scripts/setup-network.sh testnet + + chmod 600 config/testnet/zaino.toml config/testnet/zallet.toml config/testnet/zallet_identity.txt + setfacl -b config/testnet/zallet_identity.txt + + ./scripts/setup-network.sh testnet + test "$(stat -c '%a' config/testnet/zaino.toml)" = "644" + test "$(stat -c '%a' config/testnet/zallet.toml)" = "644" + test "$(stat -c '%a' config/testnet/zallet_identity.txt)" = "600" + getfacl -cpn config/testnet/zallet_identity.txt | grep -q '^user:1000:r--$' + + ZALLET_USER="$(docker compose --env-file .env.testnet config --format json \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["zallet"]["user"])')" + docker run --rm --user "$ZALLET_USER" -v "$PWD/config/testnet":/c:ro alpine \ + sh -c 'cat /c/zallet.toml >/dev/null && cat /c/zallet_identity.txt >/dev/null' + + - name: Regtest pwhash rewrite keeps files readable + run: | + set -euo pipefail + umask 077 + ./scripts/regtest-init.sh --prepare-only + + if grep -q '__GENERATED_BY_INIT_SH__' config/regtest/zallet.toml; then + echo "regtest zallet.toml still has the pwhash placeholder" >&2 + exit 1 + fi + test "$(stat -c '%a' config/regtest/zaino.toml)" = "644" + test "$(stat -c '%a' config/regtest/zallet.toml)" = "644" + test "$(stat -c '%a' config/regtest/zebra.toml)" = "644" + test "$(stat -c '%a' config/regtest/zallet_identity.txt)" = "600" + getfacl -cpn config/regtest/zallet_identity.txt | grep -q '^user:1000:r--$' + + ZALLET_USER="$(docker compose --env-file .env.regtest config --format json \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["zallet"]["user"])')" + docker run --rm --user "$ZALLET_USER" -v "$PWD/config/regtest":/c:ro alpine \ + sh -c 'cat /c/zallet.toml >/dev/null && cat /c/zallet_identity.txt >/dev/null' + regtest-smoke: name: Regtest smoke test runs-on: ubuntu-latest @@ -74,26 +159,22 @@ jobs: with: persist-credentials: false - - name: Stage local files (live TOMLs from templates, identity) - # Mirrors the file setup step without requiring rage in CI. The sed - # command patches the live gitignored copy, not the tracked template. - run: | - cp config/regtest/zallet.toml.example config/regtest/zallet.toml - cp config/regtest/zaino.toml.example config/regtest/zaino.toml - cp config/regtest/zebra.toml.example config/regtest/zebra.toml - echo "# dummy identity for CI" > config/regtest/zallet_identity.txt - SALT=$(openssl rand -hex 16) - HASH=$(printf 'zebra' | openssl dgst -sha256 -mac HMAC -macopt "key:$SALT" | awk '{print $NF}') - sed -i "s|__GENERATED_BY_INIT_SH__|${SALT}\$${HASH}|" config/regtest/zallet.toml - # Mirror the permission end-state of setup-network.sh / regtest-init.sh - # so the readability assertion below exercises the real contract: - # non-secret TOMLs world-readable, the age key 0600 + ACL for uid 1000. - # Install acl explicitly so the setfacl call (and the ACL the uid-1000 - # readability assertion depends on) does not rely on the runner image. + - name: Stage regtest files with setup scripts + run: | sudo apt-get update -qq && sudo apt-get install -y -qq acl - chmod 644 config/regtest/zallet.toml config/regtest/zaino.toml config/regtest/zebra.toml - chmod 600 config/regtest/zallet_identity.txt - setfacl -m u:1000:r config/regtest/zallet_identity.txt + mkdir -p "$RUNNER_TEMP/fake-bin" + cat > "$RUNNER_TEMP/fake-bin/rage-keygen" <<'EOF' + #!/usr/bin/env sh + set -eu + if [ "$#" -ne 2 ] || [ "$1" != "-o" ]; then + echo "usage: rage-keygen -o " >&2 + exit 2 + fi + printf '%s\n' '# dummy identity for CI regtest smoke' > "$2" + EOF + chmod +x "$RUNNER_TEMP/fake-bin/rage-keygen" + umask 077 + PATH="$RUNNER_TEMP/fake-bin:$PATH" ./scripts/regtest-init.sh --prepare-only - name: Start Zebra (regtest) run: | @@ -145,14 +226,15 @@ jobs: - name: Verify Zallet accepts compose command run: docker compose --env-file .env.regtest run --rm --no-deps zallet --help - - name: Assert Zallet config is readable by the container uid (1000) - # Zallet runs as uid 1000 (distroless image, no runtime chown). Guards - # the config-readability contract: zallet.toml is world-readable and the - # age key is reachable by uid 1000 via ACL. Catches a regression of the - # mktemp-0600 mode leaking back onto the rewritten config. + - name: Assert Zallet config is readable by the compose user + # Guards the config-readability contract at the same UID/GID declared + # for the zallet service. Catches script regressions and compose user + # changes that would make bind-mounted config unreadable. run: | - docker run --rm --user 1000:1000 -v "$PWD/config/regtest":/c:ro alpine \ - sh -c 'cat /c/zallet.toml >/dev/null && cat /c/zallet_identity.txt >/dev/null && echo "uid 1000 read OK"' + ZALLET_USER="$(docker compose --env-file .env.regtest config --format json \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["zallet"]["user"])')" + docker run --rm --user "$ZALLET_USER" -v "$PWD/config/regtest":/c:ro alpine \ + sh -c 'cat /c/zallet.toml >/dev/null && cat /c/zallet_identity.txt >/dev/null && echo "compose user read OK"' - name: Verify the cookie volume is attachable # Regtest disables cookie auth (ZEBRA_RPC__ENABLE_COOKIE_AUTH=false) so diff --git a/README.md b/README.md index dbdcbdd..f8bb0a1 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml build - Copies `config//zaino.toml.example` → `config//zaino.toml` (same) - Generates `config//zallet_identity.txt` via `rage-keygen` if missing -Subsequent runs print which steps were skipped. Back up `zallet_identity.txt` together with the `z3--zallet` volume; without the identity file the wallet database cannot be decrypted. +Subsequent runs leave existing file contents untouched, but still reconcile generated TOML modes and the Zallet identity permissions described below. Back up `zallet_identity.txt` together with the `z3--zallet` volume; without the identity file the wallet database cannot be decrypted. ### Per-network Zallet config @@ -358,7 +358,7 @@ Fix permissions before starting: Zebra, Zaino, and Zallet each run as a specific non-root user. Directories must have correct ownership (set by the script) and `700` permissions. Never use `755` or `777`. -Operators whose host uid is not `1000` do **not** need to coordinate uids for Zallet's bind-mounted config. Zallet runs as uid 1000 (distroless image), and `setup-network.sh` makes `zallet.toml` readable by that uid (`0644`) and grants the age key `zallet_identity.txt` read access for uid 1000 via a POSIX ACL (`setfacl -m u:1000:r`) while keeping it `0600` for everyone else. When your host uid is already `1000` the `0600` key is readable as-is and no ACL is needed; otherwise install the `acl` package on Linux. If your host uid is not `1000` and `setfacl` is unavailable, `setup-network.sh` stops with instructions rather than leaving Zallet to crash at startup — install `acl` and run `setfacl -m u:1000:r` on the identity file (or `chmod 644` it to allow all local users). +Operators whose host uid is not `1000` do **not** need to coordinate uids for Zallet's bind-mounted config. Zallet runs as uid 1000 (distroless image), and `setup-network.sh` makes generated TOMLs readable by service containers (`0644`) and grants the age key `zallet_identity.txt` read access for uid 1000 via a POSIX ACL (`setfacl -m u:1000:r`) while keeping it `0600` for everyone else. The script re-applies this permission contract on every run, including existing files. When your host uid is already `1000` the `0600` key is readable as-is and no ACL is needed; otherwise install the `acl` package on Linux. If your host uid is not `1000` and `setfacl` is unavailable, `setup-network.sh` stops with instructions rather than leaving Zallet to crash at startup — install `acl` and run `setfacl -m u:1000:r` on the identity file (or `chmod 644` it to allow all local users). diff --git a/docs/docker-architecture.md b/docs/docker-architecture.md index 2092eb8..7c47c87 100644 --- a/docs/docker-architecture.md +++ b/docs/docker-architecture.md @@ -174,7 +174,7 @@ Zebra's Docker entrypoint starts as root, runs `mkdir` and `chown` to set up mou cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID] ``` -Zaino and Zallet run as non-root from the start and work with `cap_drop: [ALL]` alone. +Zallet is the only core service pinned directly to a non-root user in Compose (`user: "1000:1000"`). Zaino is not pinned with a Compose `user:` override and has the setup capabilities declared in `docker-compose.yml`, so Zallet's bind-mounted config has the strictest host-file readability requirement. ## Healthchecks @@ -270,9 +270,9 @@ Zaino and Zallet depend on the sidecar's healthcheck, so targeted starts such as ## Zallet config readability and operator uid -Zallet uses a distroless image with no shell, entrypoint script, or Linux capabilities, so the base compose pins it with `user: "1000:1000"` and it runs as that uid from PID 1 — unlike Zebra (root entrypoint that drops to uid 10001) and Zaino (root entrypoint with `cap_add: [DAC_OVERRIDE, ...]`, which bypasses file-permission checks before dropping privileges). Zallet therefore can only read its two bind-mounted host config files — `config//zallet.toml` → `/etc/zallet/zallet.toml` and `config//zallet_identity.txt` → `/etc/zallet/identity.txt` — if they are readable by uid 1000. +Zallet uses a distroless image with no shell, entrypoint script, or Linux capabilities, so the base compose pins it with `user: "1000:1000"` and it runs as that uid from PID 1. Unlike Zebra, which starts with an entrypoint that can prepare filesystem state before dropping privileges, Zallet can only read its two bind-mounted host config files — `config//zallet.toml` → `/etc/zallet/zallet.toml` and `config//zallet_identity.txt` → `/etc/zallet/identity.txt` — if they are readable by uid 1000. -The same "no UID coordination required" property that the cookie sidecar provides applies here: the setup scripts make this config readable by uid 1000 regardless of the operator's host uid. `scripts/setup-network.sh` writes `zallet.toml` (and the other non-secret TOMLs) mode `0644`; `scripts/regtest-init.sh` restores `0644` after its `mktemp`+`mv` pwhash rewrite (which would otherwise leave the file `0600`). The age key `zallet_identity.txt` is a long-lived wallet secret, so instead of widening it to all local users it stays mode `0600` with a POSIX ACL granting read to uid 1000 only (`setfacl -m u:1000:r`). The ACL only matters when the operator's host uid is not 1000 — when it is 1000 the `0600` key is already owned by, and readable as, uid 1000, so `setup-network.sh` skips the ACL. When the host uid is not 1000, `setfacl` (the `acl` package on Linux, with an ACL-capable filesystem such as ext4/xfs) is required: if it is unavailable or fails, the script stops with instructions rather than letting Zallet crash at startup, and the operator installs `acl` (or falls back to `chmod 644` on the identity file to allow all local users). +The same "no UID coordination required" property that the cookie sidecar provides applies here: the setup scripts make generated TOML config readable by service containers regardless of the operator's host uid, and they re-apply that permission contract on every run. `scripts/setup-network.sh` keeps generated TOMLs mode `0644`; `scripts/regtest-init.sh` restores `0644` after its `mktemp`+`mv` pwhash rewrite (which would otherwise leave `zallet.toml` mode `0600`). The age key `zallet_identity.txt` is a long-lived wallet secret, so instead of widening it to all local users it stays mode `0600` with a POSIX ACL granting read to uid 1000 only (`setfacl -m u:1000:r`). The ACL only matters when the operator's host uid is not 1000 — when it is 1000 the `0600` key is already owned by, and readable as, uid 1000, so `setup-network.sh` skips the ACL. When the host uid is not 1000, `setfacl` (the `acl` package on Linux, with an ACL-capable filesystem such as ext4/xfs) is required: if it is unavailable or fails, the script stops with instructions rather than letting Zallet crash at startup, and the operator installs `acl` (or falls back to `chmod 644` on the identity file to allow all local users). CI runs these setup paths with restrictive host modes, derives Zallet's UID/GID from rendered Compose, and verifies a container running as that user can read both bind-mounted Zallet files. ## Regtest overlay constraints diff --git a/scripts/regtest-init.sh b/scripts/regtest-init.sh index 0df7f8d..c7a7fdf 100755 --- a/scripts/regtest-init.sh +++ b/scripts/regtest-init.sh @@ -6,14 +6,29 @@ # activation blocks, and generate the wallet mnemonic. # # Run this once before starting the regtest stack. Safe to re-run. +# Use --prepare-only in CI to exercise file setup and pwhash generation without +# starting Docker services. # # Requirements: -# - Docker with Docker Compose v2.24.4+ +# - Docker with Docker Compose v2.24.4+ (unless using --prepare-only) # - rage-keygen, openssl (used by setup-network.sh) # - No running z3-regtest-* containers set -euo pipefail +PREPARE_ONLY=0 +case "${1:-}" in + --prepare-only) + PREPARE_ONLY=1 + ;; + "") + ;; + *) + echo "Usage: $0 [--prepare-only]" >&2 + exit 1 + ;; +esac + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" ENV_FILE="$REPO_ROOT/.env.regtest" @@ -34,6 +49,13 @@ log() { printf '%s\n' "$*" } +ensure_zallet_config_readable() { + local config_path="$1" + + chmod 644 "$config_path" + log "==> Ensured ${CONFIG_DIR}/zallet.toml is readable by zallet uid 1000" +} + require_compose_v2() { # This stack relies on the colon-separated COMPOSE_FILE merge and the # !override tag, both Docker Compose v2.24.4+ features. The legacy v1 @@ -88,6 +110,7 @@ update_zallet_rpc_pwhash() { fi if ! grep -q "pwhash = \"${placeholder}\"" "$config_path"; then + ensure_zallet_config_readable "$config_path" log "==> Zallet RPC pwhash already generated, skipping." return fi @@ -109,11 +132,21 @@ update_zallet_rpc_pwhash() { mv "$tmp" "$config_path" # mktemp creates the temp file mode 0600 and mv carries that mode onto the # config; restore world-read so the zallet container (uid 1000) can read it. - chmod 644 "$config_path" + ensure_zallet_config_readable "$config_path" log "==> Generated zallet RPC pwhash in ${CONFIG_DIR}/zallet.toml" } +cd "$REPO_ROOT" + +"$SCRIPT_DIR/setup-network.sh" regtest +update_zallet_rpc_pwhash + +if [ "$PREPARE_ONLY" -eq 1 ]; then + log "==> Regtest files prepared; skipping Docker startup (--prepare-only)." + exit 0 +fi + require_compose_v2 DOCKER="docker" @@ -122,11 +155,6 @@ if ! docker info > /dev/null 2>&1; then COMPOSE="sudo -E $COMPOSE" fi -cd "$REPO_ROOT" - -"$SCRIPT_DIR/setup-network.sh" regtest -update_zallet_rpc_pwhash - # Clean up any leftover containers from previous runs. $COMPOSE down --remove-orphans 2>/dev/null || true diff --git a/scripts/setup-network.sh b/scripts/setup-network.sh index 4a335ff..67de9da 100755 --- a/scripts/setup-network.sh +++ b/scripts/setup-network.sh @@ -29,6 +29,7 @@ esac SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" CONFIG_DIR="$REPO_ROOT/config/$NETWORK" +ZALLET_UID=1000 log() { printf '%s\n' "$*"; } @@ -38,7 +39,7 @@ copy_template() { local active="$CONFIG_DIR/$file" if [ -f "$active" ]; then - log "==> $NETWORK/$file: present, leaving operator copy untouched." + log "==> $NETWORK/$file: present, leaving contents untouched." return fi @@ -48,45 +49,61 @@ copy_template() { fi cp "$example" "$active" - # Make the config world-readable so the pinned zallet container uid (1000) - # can read it regardless of the operator's host uid/umask. These TOMLs are - # not secret; the age key is handled separately in ensure_identity. - chmod 644 "$active" log "==> $NETWORK/$file: created from .example template." } +ensure_toml_readable() { + local file="$1" + local config="$CONFIG_DIR/$file" + + if [ ! -f "$config" ]; then + log "FAIL: missing config $config" >&2 + exit 1 + fi + + chmod 644 "$config" + log "==> $NETWORK/$file: ensured readable by service containers." +} + +grant_zallet_uid_read() { + local file="$1" + + # Zallet runs as uid 1000 (distroless image, no runtime chown), so it can + # only read bind-mounted secrets if uid 1000 has read access. When the + # operator's host uid is already 1000 the 0600 file is readable as-is; + # otherwise grant uid 1000 read via POSIX ACL without widening to others. + if [ "$(id -u)" -eq "$ZALLET_UID" ]; then + return + fi + + if command -v setfacl >/dev/null 2>&1 && setfacl -m "u:${ZALLET_UID}:r" "$file"; then + return + fi + + log "FAIL: cannot grant uid $ZALLET_UID read on $file (host uid $(id -u) != $ZALLET_UID)." >&2 + log " zallet (uid $ZALLET_UID) could not read the file and would fail to start." >&2 + log " Install the 'acl' package and run: setfacl -m u:${ZALLET_UID}:r $file" >&2 + log " (or 'chmod 644 $file' to allow all local users)." >&2 + exit 1 +} + ensure_identity() { local identity="$CONFIG_DIR/zallet_identity.txt" if [ -f "$identity" ]; then log "==> $NETWORK/zallet_identity.txt: present." - return - fi - - if ! command -v rage-keygen >/dev/null 2>&1; then + elif ! command -v rage-keygen >/dev/null 2>&1; then log "FAIL: rage-keygen not found." >&2 log " Install rage from https://github.com/str4d/rage/releases" >&2 exit 1 + else + rage-keygen -o "$identity" + log "==> $NETWORK/zallet_identity.txt: generated." fi - rage-keygen -o "$identity" chmod 600 "$identity" - # Zallet runs as uid 1000 (distroless image, no runtime chown), so it can - # only read the age key if uid 1000 has read access. When the operator's - # host uid is already 1000 the 0600 file is readable as-is; otherwise grant - # uid 1000 read via a POSIX ACL without widening the key to other users. - if [ "$(id -u)" -ne 1000 ]; then - if command -v setfacl >/dev/null 2>&1 && setfacl -m u:1000:r "$identity"; then - : # uid 1000 granted read via ACL - else - log "FAIL: cannot grant uid 1000 read on $identity (host uid $(id -u) != 1000)." >&2 - log " zallet (uid 1000) could not read the age key and would fail to start." >&2 - log " Install the 'acl' package and run: setfacl -m u:1000:r $identity" >&2 - log " (or 'chmod 644 $identity' to allow all local users)." >&2 - exit 1 - fi - fi - log "==> $NETWORK/zallet_identity.txt: generated." + grant_zallet_uid_read "$identity" + log "==> $NETWORK/zallet_identity.txt: ensured readable by zallet uid $ZALLET_UID." } mkdir -p "$CONFIG_DIR" @@ -98,6 +115,11 @@ copy_template zallet.toml if [ "$NETWORK" = "regtest" ]; then copy_template zebra.toml fi +ensure_toml_readable zaino.toml +ensure_toml_readable zallet.toml +if [ "$NETWORK" = "regtest" ]; then + ensure_toml_readable zebra.toml +fi ensure_identity log From 406308c46881c3722c7a2437e7bbe662b1cab25f Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Fri, 19 Jun 2026 07:10:07 -0700 Subject: [PATCH 4/4] fix: assert zallet identity ACL semantics --- .github/workflows/ci.yaml | 21 +++++++++++++++------ README.md | 2 +- docs/docker-architecture.md | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0b06e93..21991c4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -95,8 +95,11 @@ jobs: test "$(stat -c '%a' config/mainnet/zaino.toml)" = "644" test "$(stat -c '%a' config/mainnet/zallet.toml)" = "644" - test "$(stat -c '%a' config/mainnet/zallet_identity.txt)" = "600" - getfacl -cpn config/mainnet/zallet_identity.txt | grep -q '^user:1000:r--$' + getfacl -cpn config/mainnet/zallet_identity.txt > /tmp/mainnet-zallet-identity.acl + grep -q '^user::rw-$' /tmp/mainnet-zallet-identity.acl + grep -q '^user:1000:r--$' /tmp/mainnet-zallet-identity.acl + grep -q '^group::---$' /tmp/mainnet-zallet-identity.acl + grep -q '^other::---$' /tmp/mainnet-zallet-identity.acl ZALLET_USER="$(docker compose --env-file .env.mainnet config --format json \ | python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["zallet"]["user"])')" @@ -115,8 +118,11 @@ jobs: ./scripts/setup-network.sh testnet test "$(stat -c '%a' config/testnet/zaino.toml)" = "644" test "$(stat -c '%a' config/testnet/zallet.toml)" = "644" - test "$(stat -c '%a' config/testnet/zallet_identity.txt)" = "600" - getfacl -cpn config/testnet/zallet_identity.txt | grep -q '^user:1000:r--$' + getfacl -cpn config/testnet/zallet_identity.txt > /tmp/testnet-zallet-identity.acl + grep -q '^user::rw-$' /tmp/testnet-zallet-identity.acl + grep -q '^user:1000:r--$' /tmp/testnet-zallet-identity.acl + grep -q '^group::---$' /tmp/testnet-zallet-identity.acl + grep -q '^other::---$' /tmp/testnet-zallet-identity.acl ZALLET_USER="$(docker compose --env-file .env.testnet config --format json \ | python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["zallet"]["user"])')" @@ -136,8 +142,11 @@ jobs: test "$(stat -c '%a' config/regtest/zaino.toml)" = "644" test "$(stat -c '%a' config/regtest/zallet.toml)" = "644" test "$(stat -c '%a' config/regtest/zebra.toml)" = "644" - test "$(stat -c '%a' config/regtest/zallet_identity.txt)" = "600" - getfacl -cpn config/regtest/zallet_identity.txt | grep -q '^user:1000:r--$' + getfacl -cpn config/regtest/zallet_identity.txt > /tmp/regtest-zallet-identity.acl + grep -q '^user::rw-$' /tmp/regtest-zallet-identity.acl + grep -q '^user:1000:r--$' /tmp/regtest-zallet-identity.acl + grep -q '^group::---$' /tmp/regtest-zallet-identity.acl + grep -q '^other::---$' /tmp/regtest-zallet-identity.acl ZALLET_USER="$(docker compose --env-file .env.regtest config --format json \ | python3 -c 'import json, sys; print(json.load(sys.stdin)["services"]["zallet"]["user"])')" diff --git a/README.md b/README.md index f8bb0a1..87ce90f 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ Fix permissions before starting: Zebra, Zaino, and Zallet each run as a specific non-root user. Directories must have correct ownership (set by the script) and `700` permissions. Never use `755` or `777`. -Operators whose host uid is not `1000` do **not** need to coordinate uids for Zallet's bind-mounted config. Zallet runs as uid 1000 (distroless image), and `setup-network.sh` makes generated TOMLs readable by service containers (`0644`) and grants the age key `zallet_identity.txt` read access for uid 1000 via a POSIX ACL (`setfacl -m u:1000:r`) while keeping it `0600` for everyone else. The script re-applies this permission contract on every run, including existing files. When your host uid is already `1000` the `0600` key is readable as-is and no ACL is needed; otherwise install the `acl` package on Linux. If your host uid is not `1000` and `setfacl` is unavailable, `setup-network.sh` stops with instructions rather than leaving Zallet to crash at startup — install `acl` and run `setfacl -m u:1000:r` on the identity file (or `chmod 644` it to allow all local users). +Operators whose host uid is not `1000` do **not** need to coordinate uids for Zallet's bind-mounted config. Zallet runs as uid 1000 (distroless image), and `setup-network.sh` makes generated TOMLs readable by service containers (`0644`) and grants the age key `zallet_identity.txt` read access for uid 1000 via a POSIX ACL (`setfacl -m u:1000:r`) while keeping group and other access closed. The script re-applies this permission contract on every run, including existing files. When your host uid is already `1000` the owner-only key is readable as-is and no ACL is needed; otherwise install the `acl` package on Linux. If your host uid is not `1000` and `setfacl` is unavailable, `setup-network.sh` stops with instructions rather than leaving Zallet to crash at startup — install `acl` and run `setfacl -m u:1000:r` on the identity file (or `chmod 644` it to allow all local users). diff --git a/docs/docker-architecture.md b/docs/docker-architecture.md index 7c47c87..710a74c 100644 --- a/docs/docker-architecture.md +++ b/docs/docker-architecture.md @@ -272,7 +272,7 @@ Zaino and Zallet depend on the sidecar's healthcheck, so targeted starts such as Zallet uses a distroless image with no shell, entrypoint script, or Linux capabilities, so the base compose pins it with `user: "1000:1000"` and it runs as that uid from PID 1. Unlike Zebra, which starts with an entrypoint that can prepare filesystem state before dropping privileges, Zallet can only read its two bind-mounted host config files — `config//zallet.toml` → `/etc/zallet/zallet.toml` and `config//zallet_identity.txt` → `/etc/zallet/identity.txt` — if they are readable by uid 1000. -The same "no UID coordination required" property that the cookie sidecar provides applies here: the setup scripts make generated TOML config readable by service containers regardless of the operator's host uid, and they re-apply that permission contract on every run. `scripts/setup-network.sh` keeps generated TOMLs mode `0644`; `scripts/regtest-init.sh` restores `0644` after its `mktemp`+`mv` pwhash rewrite (which would otherwise leave `zallet.toml` mode `0600`). The age key `zallet_identity.txt` is a long-lived wallet secret, so instead of widening it to all local users it stays mode `0600` with a POSIX ACL granting read to uid 1000 only (`setfacl -m u:1000:r`). The ACL only matters when the operator's host uid is not 1000 — when it is 1000 the `0600` key is already owned by, and readable as, uid 1000, so `setup-network.sh` skips the ACL. When the host uid is not 1000, `setfacl` (the `acl` package on Linux, with an ACL-capable filesystem such as ext4/xfs) is required: if it is unavailable or fails, the script stops with instructions rather than letting Zallet crash at startup, and the operator installs `acl` (or falls back to `chmod 644` on the identity file to allow all local users). CI runs these setup paths with restrictive host modes, derives Zallet's UID/GID from rendered Compose, and verifies a container running as that user can read both bind-mounted Zallet files. +The same "no UID coordination required" property that the cookie sidecar provides applies here: the setup scripts make generated TOML config readable by service containers regardless of the operator's host uid, and they re-apply that permission contract on every run. `scripts/setup-network.sh` keeps generated TOMLs mode `0644`; `scripts/regtest-init.sh` restores `0644` after its `mktemp`+`mv` pwhash rewrite (which would otherwise leave `zallet.toml` mode `0600`). The age key `zallet_identity.txt` is a long-lived wallet secret, so instead of widening it to all local users it keeps group and other access closed while granting read to uid 1000 only via POSIX ACL (`setfacl -m u:1000:r`). The ACL only matters when the operator's host uid is not 1000 — when it is 1000 the owner-only key is already owned by, and readable as, uid 1000, so `setup-network.sh` skips the ACL. When the host uid is not 1000, `setfacl` (the `acl` package on Linux, with an ACL-capable filesystem such as ext4/xfs) is required: if it is unavailable or fails, the script stops with instructions rather than letting Zallet crash at startup, and the operator installs `acl` (or falls back to `chmod 644` on the identity file to allow all local users). CI runs these setup paths with restrictive host modes, derives Zallet's UID/GID from rendered Compose, verifies the ACL entries, and verifies a container running as that user can read both bind-mounted Zallet files. ## Regtest overlay constraints