diff --git a/Cargo.toml b/Cargo.toml index 2de396592..65948e4c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ repository = "https://github.com/zingolabs" homepage = "https://www.zingolabs.org/" edition = "2021" license = "Apache-2.0" -version = "0.1.2" +version = "0.2.0" [workspace.dependencies] diff --git a/Dockerfile b/Dockerfile index c54b2a50f..3b2ccafde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ fi ############################ -# Runtime (slim, non-root) +# Runtime ############################ FROM debian:bookworm-slim AS runtime SHELL ["/bin/bash", "-euo", "pipefail", "-c"] @@ -50,33 +50,43 @@ ARG GID ARG USER ARG HOME -# Only the dynamic libs needed by a Rust/OpenSSL binary +# Runtime deps + setpriv for privilege dropping RUN apt-get -qq update && \ apt-get -qq install -y --no-install-recommends \ - ca-certificates libssl3 libgcc-s1 \ + ca-certificates libssl3 libgcc-s1 util-linux \ && rm -rf /var/lib/apt/lists/* -# Create non-root user +# Create non-root user (entrypoint will drop privileges to this user) RUN addgroup --gid "${GID}" "${USER}" && \ adduser --uid "${UID}" --gid "${GID}" --home "${HOME}" \ --disabled-password --gecos "" "${USER}" +# Make UID/GID available to entrypoint +ENV UID=${UID} GID=${GID} HOME=${HOME} + WORKDIR ${HOME} -# Copy the installed binary from builder -COPY --from=builder /out/bin/zainod /usr/local/bin/zainod +# Create ergonomic mount points with symlinks to XDG defaults +# Users mount to /app/config and /app/data, zaino uses ~/.config/zaino and ~/.cache/zaino +RUN mkdir -p /app/config /app/data && \ + mkdir -p ${HOME}/.config ${HOME}/.cache && \ + ln -s /app/config ${HOME}/.config/zaino && \ + ln -s /app/data ${HOME}/.cache/zaino && \ + chown -R ${UID}:${GID} /app ${HOME}/.config ${HOME}/.cache -RUN mkdir -p .cache/zaino -RUN chown -R "${UID}:${GID}" "${HOME}" -USER ${USER} +# Copy binary and entrypoint +COPY --from=builder /out/bin/zainod /usr/local/bin/zainod +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh -# Default ports (adjust if your app uses different ones) +# Default ports ARG ZAINO_GRPC_PORT=8137 ARG ZAINO_JSON_RPC_PORT=8237 EXPOSE ${ZAINO_GRPC_PORT} ${ZAINO_JSON_RPC_PORT} -# Healthcheck that doesn't assume specific HTTP/gRPC endpoints HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD /usr/local/bin/zainod --version >/dev/null 2>&1 || exit 1 -CMD ["zainod"] +# Start as root; entrypoint drops privileges after setting up directories +ENTRYPOINT ["/entrypoint.sh"] +CMD ["start"] diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 000000000..13adae542 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,136 @@ +# Docker Usage + +This document covers running Zaino using the official Docker image. + +## Overview + +The Docker image runs `zainod` - the Zaino indexer daemon. The image: + +- Uses `zainod` as the entrypoint with `start` as the default subcommand +- Runs as non-root user (`container_user`, UID 1000) after initial setup +- Handles volume permissions automatically for default paths + +For CLI usage details, see the CLI documentation or run `docker run --rm zaino --help`. + +## Configuration Options + +The container can be configured via: + +1. **Environment variables only** - Suitable for simple deployments, but sensitive fields (passwords, secrets, tokens, cookies, private keys) cannot be set via env vars for security reasons +2. **Config file + env vars** - Mount a config file for sensitive fields, override others with env vars +3. **Config file only** - Mount a complete config file + +For data persistence, volume mounts are recommended for the database/cache directory. + +## Deployment with Docker Compose + +The recommended way to run Zaino is with Docker Compose, typically alongside Zebra: + +```yaml +services: + zaino: + image: zaino:latest + ports: + - "8137:8137" # gRPC + - "8237:8237" # JSON-RPC (if enabled) + volumes: + - ./config:/app/config:ro + - zaino-data:/app/data + environment: + - ZAINO_VALIDATOR_SETTINGS__VALIDATOR_JSONRPC_LISTEN_ADDRESS=zebra:18232 + depends_on: + - zebra + + zebra: + image: zfnd/zebra:latest + volumes: + - zebra-data:/home/zebra/.cache/zebra + # ... zebra configuration + +volumes: + zaino-data: + zebra-data: +``` + +If Zebra runs on a different host/network, adjust `VALIDATOR_JSONRPC_LISTEN_ADDRESS` accordingly. + +## Initial Setup: Generating Configuration + +To generate a config file on your host for customization: + +```bash +mkdir -p ./config + +docker run --rm -v ./config:/app/config zaino generate-config + +# Config is now at ./config/zainod.toml - edit as needed +``` + +## Container Paths + +The container provides simple mount points: + +| Purpose | Mount Point | +|---------|-------------| +| Config | `/app/config` | +| Database | `/app/data` | + +These are symlinked internally to the XDG paths that Zaino expects. + +## Volume Permission Handling + +The entrypoint handles permissions automatically: + +1. Container starts as root +2. Creates directories and sets ownership to UID 1000 +3. Drops privileges and runs `zainod` + +This means you can mount volumes without pre-configuring ownership. + +### Read-Only Config Mounts + +Config files can (and should) be mounted read-only: + +```yaml +volumes: + - ./config:/app/config:ro +``` + +## Configuration via Environment Variables + +Config values can be set via environment variables prefixed with `ZAINO_`, using `__` for nesting: + +```yaml +environment: + - ZAINO_NETWORK=Mainnet + - ZAINO_VALIDATOR_SETTINGS__VALIDATOR_JSONRPC_LISTEN_ADDRESS=zebra:18232 + - ZAINO_GRPC_SETTINGS__LISTEN_ADDRESS=0.0.0.0:8137 +``` + +### Sensitive Fields + +For security, the following fields **cannot** be set via environment variables and must use a config file: + +- `*_password` (e.g., `validator_password`) +- `*_secret` +- `*_token` +- `*_cookie` +- `*_private_key` + +If you attempt to set these via env vars, Zaino will error on startup. + +## Health Check + +The image includes a health check: + +```bash +docker inspect --format='{{.State.Health.Status}}' +``` + +## Local Testing + +Permission handling can be tested locally: + +```bash +./test_environment/test-docker-permissions.sh zaino:latest +``` diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 000000000..bf19242ac --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +# Entrypoint for running Zaino in Docker. +# +# This script handles privilege dropping and directory setup for default paths. +# Configuration is managed by config-rs using defaults, optional TOML, and +# environment variables prefixed with ZAINO_. +# +# NOTE: This script only handles directories specified via environment variables +# or the defaults below. If you configure custom paths in a TOML config file, +# you are responsible for ensuring those directories exist with appropriate +# permissions before starting the container. + +set -eo pipefail + +# Default writable paths. +# The Dockerfile creates symlinks: /app/config -> ~/.config/zaino, /app/data -> ~/.cache/zaino +# So we handle /app/* paths directly for Docker users. +# +# Database path (symlinked from ~/.cache/zaino) +: "${ZAINO_STORAGE__DATABASE__PATH:=/app/data}" +# +# Cookie dir (runtime, ephemeral) +: "${ZAINO_JSON_SERVER_SETTINGS__COOKIE_DIR:=${XDG_RUNTIME_DIR:-/tmp}/zaino}" +# +# Config directory (symlinked from ~/.config/zaino) +ZAINO_CONFIG_DIR="/app/config" + +# Drop privileges and execute command as non-root user +exec_as_user() { + user=$(id -u) + if [[ ${user} == '0' ]]; then + exec setpriv --reuid="${UID}" --regid="${GID}" --init-groups "$@" + else + exec "$@" + fi +} + +exit_error() { + echo "ERROR: $1" >&2 + exit 1 +} + +# Creates a directory if it doesn't exist and sets ownership to UID:GID. +# Gracefully handles read-only mounts by skipping chown if it fails. +create_owned_directory() { + local dir="$1" + [[ -z ${dir} ]] && return + + # Try to create directory; skip if read-only + if ! mkdir -p "${dir}" 2>/dev/null; then + echo "WARN: Cannot create ${dir} (read-only or permission denied), skipping" + return 0 + fi + + # Try to set ownership; skip if read-only + if ! chown -R "${UID}:${GID}" "${dir}" 2>/dev/null; then + echo "WARN: Cannot chown ${dir} (read-only?), skipping" + return 0 + fi + + # Set ownership on parent if it's not root or home + local parent_dir + parent_dir="$(dirname "${dir}")" + if [[ "${parent_dir}" != "/" && "${parent_dir}" != "${HOME}" ]]; then + chown "${UID}:${GID}" "${parent_dir}" 2>/dev/null || true + fi +} + +# Create and set ownership on writable directories +create_owned_directory "${ZAINO_STORAGE__DATABASE__PATH}" +create_owned_directory "${ZAINO_JSON_SERVER_SETTINGS__COOKIE_DIR}" +create_owned_directory "${ZAINO_CONFIG_DIR}" + +# Execute zainod with dropped privileges +exec_as_user zainod "$@" diff --git a/test_environment/test-docker-permissions.sh b/test_environment/test-docker-permissions.sh new file mode 100755 index 000000000..23aeaa29c --- /dev/null +++ b/test_environment/test-docker-permissions.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +# Local Docker permission tests for zaino image. +# These tests verify the entrypoint correctly handles volume mounts +# with various ownership scenarios. +# +# Usage: ./test-docker-permissions.sh [image-name] +# Default image: zaino:test-entrypoint + +set -uo pipefail + +IMAGE="${1:-zaino:test-entrypoint}" +TEST_DIR="/tmp/zaino-docker-tests-$$" +PASSED=0 +FAILED=0 + +cleanup() { + echo "Cleaning up ${TEST_DIR}..." + rm -rf "${TEST_DIR}" +} +trap cleanup EXIT + +mkdir -p "${TEST_DIR}" + +pass() { + echo "✅ $1" + ((PASSED++)) +} + +fail() { + echo "❌ $1" + ((FAILED++)) +} + +run_test() { + local name="$1" + shift + echo "--- Testing: ${name} ---" + if "$@"; then + pass "${name}" + else + fail "${name}" + fi + echo +} + +# Basic smoke tests +run_test "help command" \ + docker run --rm "${IMAGE}" --help + +run_test "version command" \ + docker run --rm "${IMAGE}" --version + +run_test "generate-config command" \ + docker run --rm "${IMAGE}" generate-config + +run_test "start --help command" \ + docker run --rm "${IMAGE}" start --help + +# Volume mount tests - using /app paths +test_config_mount() { + local dir="${TEST_DIR}/config" + mkdir -p "${dir}" + docker run --rm -v "${dir}:/app/config" "${IMAGE}" generate-config + test -f "${dir}/zainod.toml" +} +run_test "config dir mount (/app/config)" test_config_mount + +test_data_mount() { + local dir="${TEST_DIR}/data" + mkdir -p "${dir}" + docker run --rm -v "${dir}:/app/data" "${IMAGE}" --version +} +run_test "data dir mount (/app/data)" test_data_mount + +# File ownership verification +test_file_ownership() { + local dir="${TEST_DIR}/ownership-test" + mkdir -p "${dir}" + docker run --rm -v "${dir}:/app/config" "${IMAGE}" generate-config + # File should be owned by UID 1000 (container_user) + local uid + uid=$(stat -c '%u' "${dir}/zainod.toml" 2>/dev/null || stat -f '%u' "${dir}/zainod.toml") + test "${uid}" = "1000" +} +run_test "files created with correct UID (1000)" test_file_ownership + +# Root-owned directory tests (requires sudo) +if command -v sudo &>/dev/null && sudo -n true 2>/dev/null; then + test_root_owned_mount() { + local dir="${TEST_DIR}/root-owned" + sudo mkdir -p "${dir}" + sudo chown root:root "${dir}" + docker run --rm -v "${dir}:/app/data" "${IMAGE}" --version + # Entrypoint should have chowned it + local uid + uid=$(stat -c '%u' "${dir}" 2>/dev/null || stat -f '%u' "${dir}") + test "${uid}" = "1000" + } + run_test "root-owned dir gets chowned" test_root_owned_mount + + test_root_owned_config_write() { + local dir="${TEST_DIR}/root-config" + sudo mkdir -p "${dir}" + sudo chown root:root "${dir}" + docker run --rm -v "${dir}:/app/config" "${IMAGE}" generate-config + test -f "${dir}/zainod.toml" + } + run_test "write to root-owned config dir" test_root_owned_config_write +else + echo "⚠️ Skipping root-owned tests (sudo not available or requires password)" +fi + +# Read-only config mount +test_readonly_config() { + local dir="${TEST_DIR}/ro-config" + mkdir -p "${dir}" + # First generate a config + docker run --rm -v "${dir}:/app/config" "${IMAGE}" generate-config + # Then mount it read-only and verify we can still run + docker run --rm -v "${dir}:/app/config:ro" "${IMAGE}" --version +} +run_test "read-only config mount" test_readonly_config + +# Summary +echo "=========================================" +echo "Results: ${PASSED} passed, ${FAILED} failed" +echo "=========================================" + +if [[ ${FAILED} -gt 0 ]]; then + exit 1 +fi diff --git a/zaino-common/src/xdg.rs b/zaino-common/src/xdg.rs index 6e741adf2..4a530cecc 100644 --- a/zaino-common/src/xdg.rs +++ b/zaino-common/src/xdg.rs @@ -39,6 +39,12 @@ pub enum XdgDir { /// /// Default: `$HOME/.cache` Cache, + + /// `XDG_RUNTIME_DIR` - Runtime files (sockets, locks, cookies). + /// + /// Per XDG spec, there is no standard default if unset. + /// We fall back to `/tmp` for practical usability. + Runtime, // /// `XDG_DATA_HOME` - User data files. // /// // /// Default: `$HOME/.local/share` @@ -48,11 +54,6 @@ pub enum XdgDir { // /// // /// Default: `$HOME/.local/state` // State, - - // /// `XDG_RUNTIME_DIR` - Runtime files (sockets, locks). - // /// - // /// No standard default; falls back to `/tmp` if unset. - // Runtime, } impl XdgDir { @@ -61,14 +62,18 @@ impl XdgDir { match self { Self::Config => "XDG_CONFIG_HOME", Self::Cache => "XDG_CACHE_HOME", + Self::Runtime => "XDG_RUNTIME_DIR", } } /// Returns the fallback subdirectory relative to `$HOME`. - pub fn home_subdir(&self) -> &'static str { + /// + /// Note: `Runtime` returns `None` as XDG spec defines no $HOME fallback for it. + pub fn home_subdir(&self) -> Option<&'static str> { match self { - Self::Config => ".config", - Self::Cache => ".cache", + Self::Config => Some(".config"), + Self::Cache => Some(".cache"), + Self::Runtime => None, } } } @@ -77,24 +82,36 @@ impl XdgDir { /// /// # Resolution Order /// +/// For `Config` and `Cache`: /// 1. If the XDG environment variable is set, uses that as the base /// 2. Falls back to `$HOME/{xdg_subdir}/{subpath}` /// 3. Falls back to `/tmp/zaino/{xdg_subdir}/{subpath}` if HOME is unset +/// +/// For `Runtime`: +/// 1. If `XDG_RUNTIME_DIR` is set, uses that as the base +/// 2. Falls back to `/tmp/{subpath}` (no $HOME fallback per XDG spec) fn resolve_path_with_xdg_defaults(dir: XdgDir, subpath: &str) -> PathBuf { // Try XDG environment variable first if let Ok(xdg_base) = std::env::var(dir.env_var()) { return PathBuf::from(xdg_base).join(subpath); } - // Fall back to $HOME/{subdir} + // Runtime has no $HOME fallback per XDG spec + if dir == XdgDir::Runtime { + return PathBuf::from("/tmp").join(subpath); + } + + // Fall back to $HOME/{subdir} for Config and Cache if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home).join(dir.home_subdir()).join(subpath); + if let Some(subdir) = dir.home_subdir() { + return PathBuf::from(home).join(subdir).join(subpath); + } } // Final fallback to /tmp/zaino/{subdir} PathBuf::from("/tmp") .join("zaino") - .join(dir.home_subdir()) + .join(dir.home_subdir().unwrap_or("")) .join(subpath) } @@ -134,6 +151,26 @@ pub fn resolve_path_with_xdg_cache_defaults(subpath: &str) -> PathBuf { resolve_path_with_xdg_defaults(XdgDir::Cache, subpath) } +/// Resolves a path using `XDG_RUNTIME_DIR` defaults. +/// +/// Convenience wrapper for [`resolve_path_with_xdg_defaults`] with [`XdgDir::Runtime`]. +/// +/// Note: Per XDG spec, `XDG_RUNTIME_DIR` has no `$HOME` fallback. If unset, +/// this falls back directly to `/tmp/{subpath}`. +/// +/// # Example +/// +/// ``` +/// use zaino_common::xdg::resolve_path_with_xdg_runtime_defaults; +/// +/// let path = resolve_path_with_xdg_runtime_defaults("zaino/.cookie"); +/// // Returns: $XDG_RUNTIME_DIR/zaino/.cookie +/// // or: /tmp/zaino/.cookie +/// ``` +pub fn resolve_path_with_xdg_runtime_defaults(subpath: &str) -> PathBuf { + resolve_path_with_xdg_defaults(XdgDir::Runtime, subpath) +} + #[cfg(test)] mod tests { use super::*; @@ -142,12 +179,14 @@ mod tests { fn test_xdg_dir_env_vars() { assert_eq!(XdgDir::Config.env_var(), "XDG_CONFIG_HOME"); assert_eq!(XdgDir::Cache.env_var(), "XDG_CACHE_HOME"); + assert_eq!(XdgDir::Runtime.env_var(), "XDG_RUNTIME_DIR"); } #[test] fn test_xdg_dir_home_subdirs() { - assert_eq!(XdgDir::Config.home_subdir(), ".config"); - assert_eq!(XdgDir::Cache.home_subdir(), ".cache"); + assert_eq!(XdgDir::Config.home_subdir(), Some(".config")); + assert_eq!(XdgDir::Cache.home_subdir(), Some(".cache")); + assert_eq!(XdgDir::Runtime.home_subdir(), None); } #[test] @@ -157,5 +196,8 @@ mod tests { let cache_path = resolve_path_with_xdg_cache_defaults("zaino"); assert!(cache_path.ends_with("zaino")); + + let runtime_path = resolve_path_with_xdg_runtime_defaults("zaino/.cookie"); + assert!(runtime_path.ends_with("zaino/.cookie")); } } diff --git a/zainod/src/config.rs b/zainod/src/config.rs index f0b63265c..2f67c89b5 100644 --- a/zainod/src/config.rs +++ b/zainod/src/config.rs @@ -215,11 +215,7 @@ impl Default for ZainodConfig { /// Returns the default path for Zaino's ephemeral authentication cookie. pub fn default_ephemeral_cookie_path() -> PathBuf { - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - PathBuf::from(runtime_dir).join("zaino").join(".cookie") - } else { - PathBuf::from("/tmp").join("zaino").join(".cookie") - } + zaino_common::xdg::resolve_path_with_xdg_runtime_defaults("zaino/.cookie") } /// Loads the default file path for zebra's local db. diff --git a/zainod/src/lib.rs b/zainod/src/lib.rs index aa592be8e..5b6bb54fb 100644 --- a/zainod/src/lib.rs +++ b/zainod/src/lib.rs @@ -24,6 +24,8 @@ pub mod indexer; pub async fn run(config_path: PathBuf) -> Result<(), IndexerError> { init_logging(); + info!("zainod v{}", env!("CARGO_PKG_VERSION")); + let config = load_config(&config_path)?; loop {