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
129 changes: 119 additions & 10 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>" >&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
Expand All @@ -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 <path>" >&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: |
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml build
- Copies `config/<network>/zaino.toml.example` → `config/<network>/zaino.toml` (same)
- Generates `config/<network>/zallet_identity.txt` via `rage-keygen` if missing

Subsequent runs print which steps were skipped. Back up `zallet_identity.txt` together with the `z3-<network>-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-<network>-zallet` volume; without the identity file the wallet database cannot be decrypted.

### Per-network Zallet config

Expand Down Expand Up @@ -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).

</details>

<details>
Expand Down
8 changes: 7 additions & 1 deletion docs/docker-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<network>/zallet.toml` → `/etc/zallet/zallet.toml` and `config/<network>/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
Expand Down
43 changes: 37 additions & 6 deletions scripts/regtest-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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

Expand Down
55 changes: 48 additions & 7 deletions scripts/setup-network.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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' "$*"; }

Expand All @@ -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

Expand All @@ -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"
Expand All @@ -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
Expand Down
Loading