diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5c85560..21991c4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,6 +59,100 @@ 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" + 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"])')" + 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" + 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"])')" + 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" + 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"])')" + 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,17 +168,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. + - name: Stage regtest files with setup scripts 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 + sudo apt-get update -qq && sudo apt-get install -y -qq acl + 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: | @@ -136,6 +235,16 @@ 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 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: | + 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 # Zebra does not write /auth/.cookie. This check only verifies that the diff --git a/README.md b/README.md index c26bd4e..87ce90f 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,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 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 eb0c982..710a74c 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 @@ -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, 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 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 ### Zaino authentication diff --git a/scripts/regtest-init.sh b/scripts/regtest-init.sh index 15d8d58..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 @@ -107,10 +130,23 @@ 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. + 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" @@ -119,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 9e8b9d5..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 @@ -51,23 +52,58 @@ copy_template() { 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" - 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" @@ -79,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