diff --git a/.gitignore b/.gitignore index cd8e4b1..f84263f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target .cargo +build/ diff --git a/Dockerfile b/Dockerfile index 603854b..9cd7f0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,22 @@ # syntax=docker/dockerfile:1 + +FROM stagex/pallet-rust@sha256:9c38bf1066dd9ad1b6a6b584974dd798c2bf798985bf82e58024fbe0515592ca AS pallet-rust +FROM stagex/user-protobuf@sha256:5e67b3d3a7e7e9db9aa8ab516ffa13e54acde5f0b3d4e8638f79880ab16da72c AS protobuf +FROM stagex/user-abseil-cpp@sha256:3dca99adfda0cb631bd3a948a99c2d5f89fab517bda034ce417f222721115aa2 AS abseil-cpp +FROM stagex/core-gcc@sha256:964ffd3793c5a38ca581e9faefd19918c259f1611c4cbf5dc8be612e3a8b72f5 AS gcc +FROM stagex/core-musl@sha256:d9af23284cca2e1002cd53159ada469dfe6d6791814e72d6163c7de18d4ae701 AS musl +FROM stagex/core-libunwind@sha256:eb66122d8fc543f5e2f335bb1616f8c3a471604383e2c0a9df4a8e278505d3bc AS libunwind +FROM stagex/core-user-runtime@sha256:055ae534e1e01259449fb4e0226f035a7474674c7371a136298e8bdac65d90bb AS user-runtime + # --- Stage 1: Build with Rust --- (amd64) -FROM rust:bookworm AS builder +FROM pallet-rust AS builder +COPY --from=protobuf . / +COPY --from=abseil-cpp . / -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - libclang-dev +ENV SOURCE_DATE_EPOCH=1 +ENV CXXFLAGS="-include cstdint" +ENV ROCKSDB_USE_PKG_CONFIG=0 +ENV CARGO_HOME=/usr/local/cargo # Make a fake Rust app to keep a cached layer of compiled crates WORKDIR /usr/src/app @@ -13,26 +25,59 @@ COPY zallet/Cargo.toml ./zallet/ # Needs at least a main.rs file with a main function RUN mkdir -p zallet/src/bin/zallet && echo "fn main(){}" > zallet/src/bin/zallet/main.rs RUN mkdir zallet/tests && touch zallet/tests/cli_tests.rs + +ENV RUST_BACKTRACE=1 +ENV RUSTFLAGS="-C codegen-units=1" +ENV RUSTFLAGS="${RUSTFLAGS} -C target-feature=+crt-static" +ENV RUSTFLAGS="${RUSTFLAGS} -C link-arg=-Wl,--build-id=none" +ENV CFLAGS="-D__GNUC_PREREQ(maj,min)=1" +ENV TARGET_ARCH="x86_64-unknown-linux-musl" + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + cargo fetch --locked --target $TARGET_ARCH + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + cargo metadata --locked --format-version=1 > /dev/null 2>&1 + # Will build all dependent crates in release mode RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/src/app/target \ - cargo build --release --features rpc-cli,zcashd-import + --network=none \ + cargo build --release --frozen \ + --target ${TARGET_ARCH} \ + --features rpc-cli,zcashd-import # Copy the rest COPY . . # Build the zallet binary +# Compile & install offline RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/src/app/target \ - cargo install --locked --features rpc-cli,zcashd-import --path ./zallet --bins + --network=none \ + cargo build --release --frozen \ + --bin zallet \ + --target ${TARGET_ARCH} \ + --features rpc-cli,zcashd-import \ + && install -D -m 0755 /usr/src/app/target/${TARGET_ARCH}/release/zallet /usr/local/bin/zallet -# --- Stage 2: Minimal runtime with distroless --- -FROM gcr.io/distroless/cc-debian12 AS runtime +# --- Stage 2: layer for local binary extraction --- +FROM scratch AS export -COPY --link --from=builder /usr/local/cargo/bin/zallet /usr/local/bin/ +COPY --from=builder /usr/local/bin/zallet /zallet -# USER nonroot (UID 65532) — for K8s, use runAsUser: 65532 -USER nonroot +# --- Stage 3: Minimal runtime with stagex --- +# `stagex/core-user-runtime` sets the user to non-root by default +FROM user-runtime AS runtime +COPY --from=gcc /usr/lib/libgcc_s.so.1 /usr/lib/ +COPY --from=gcc /usr/lib/libstdc++.so.6 /usr/lib/ +COPY --from=musl /lib/ld-musl-x86_64.so.1 /lib/ +COPY --from=libunwind /lib/libunwind.so.8 /lib/ +COPY --from=builder /usr/local/bin/zallet /usr/local/bin/zallet WORKDIR /var/lib/zallet diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef9f5bd --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +# Makefile +SHELL := /bin/bash +.SHELLFLAGS := -eu -o pipefail -c +IMAGE_NAME := zallet +IMAGE_TAG := latest + +.PHONY: all build import +all: build import + +.PHONY: build +build: + @echo "Running compat check..." + @out="$$(bash utils/compat.sh)"; \ + if [[ -z "$$out" ]]; then \ + echo "Compat produced no output; proceeding to build"; \ + bash utils/build.sh; \ + else \ + echo "Compat produced output; not building."; \ + printf '%s\n' "$$out"; \ + exit 1; \ + fi + + +.PHONY: import +import: + docker load -i build/oci/zallet.tar + docker tag $(IMAGE_NAME):latest $(IMAGE_NAME):$(IMAGE_TAG) diff --git a/README.md b/README.md index 38cb66f..424521a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,32 @@ contacting us in the `#wallet-dev` channel of the See the [user guide](book/src/README.md) for information on how to set up a Zallet wallet. +## Reproducible Builds + +Zallet leverages [StageX](https://codeberg.org/stagex/stagex/) to provied a +full source bootstrapped, and deterministic/reproducible build and runtime +dependencies. This helps mitigate supply chain attacks, and especially trusting +trust style attacks and reduces trust in any single computer or individual. + +### Requirements +* Docker 25+ +* [`containerd` support](https://docs.docker.com/engine/storage/containerd/#enable-containerd-image-store-on-docker-engine) +* GNU Make + +### Usage + +* To `build` and `import` the image use the `make` command + +* The `build` commmands uses the `utils/compat.sh` and `utils/builds.sh` +in order to ensure that the user has required dependencies installed and that +the [OCI](https://opencontainers.org/) image built is deterministic by using +the appropriate flags. + +### Details + +* `stagex/core-user-runtime` is used to set user to non-root and provide a +minimal filesystem + ## License All code in this workspace is licensed under either of diff --git a/utils/build.sh b/utils/build.sh new file mode 100755 index 0000000..8dadce5 --- /dev/null +++ b/utils/build.sh @@ -0,0 +1,31 @@ +#!/bin/sh + +set -e + +DIR="$( cd "$( dirname "$0" )" && pwd )" +REPO_ROOT="$(git rev-parse --show-toplevel)" +PLATFORM="linux/amd64" +OCI_OUTPUT="$REPO_ROOT/build/oci" +DOCKERFILE="$REPO_ROOT/Dockerfile" + +export DOCKER_BUILDKIT=1 +export SOURCE_DATE_EPOCH=1 + +echo $DOCKERFILE +mkdir -p $OCI_OUTPUT + +# Build runtime image for docker run +echo "Building runtime image..." +docker build -f "$DOCKERFILE" "$REPO_ROOT" \ + --platform "$PLATFORM" \ + --target runtime \ + --output type=oci,rewrite-timestamp=true,force-compression=true,dest=$OCI_OUTPUT/zallet.tar,name=zallet \ + "$@" + +# Extract binary locally from export stage +echo "Extracting binary..." +docker build -f "$DOCKERFILE" "$REPO_ROOT" --quiet \ + --platform "$PLATFORM" \ + --target export \ + --output type=local,dest="$REPO_ROOT/build" \ + "$@" diff --git a/utils/compat.sh b/utils/compat.sh new file mode 100755 index 0000000..e5a5a11 --- /dev/null +++ b/utils/compat.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -e +readonly MIN_BASH_VERSION=5 +readonly MIN_DOCKER_VERSION=26.0.0 +readonly MIN_BUILDX_VERSION=0.13 +### Exit with error message +die() { + echo "$@" >&2 + exit 1 +} +### Bail and instruct user on missing package to install for their platform +die_pkg() { + local -r package=${1?} + local -r version=${2?} + local install_cmd + case "$OSTYPE" in + linux*) + if command -v "apt" >/dev/null; then + install_cmd="apt install ${package}" + elif command -v "yum" >/dev/null; then + install_cmd="yum install ${package}" + elif command -v "pacman" >/dev/null; then + install_cmd="pacman -Ss ${package}" + elif command -v "emerge" >/dev/null; then + install_cmd="emerge ${package}" + elif command -v "nix-env" >/dev/null; then + install_cmd="nix-env -i ${package}" + fi + ;; + bsd*) install_cmd="pkg install ${package}" ;; + darwin*) install_cmd="port install ${package}" ;; + *) die "Error: Your operating system is not supported" ;; + esac + echo "Error: ${package} ${version}+ does not appear to be installed." >&2 + [ -n "$install_cmd" ] && echo "Try: \`${install_cmd}\`" >&2 + exit 1 +} +### Check if actual binary version is >= minimum version +check_version(){ + local pkg="${1?}" + local have="${2?}" + local need="${3?}" + local i ver1 ver2 IFS='.' + [[ "$have" == "$need" ]] && return 0 + read -r -a ver1 <<< "$have" + read -r -a ver2 <<< "$need" + for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)); + do ver1[i]=0; + done + for ((i=0; i<${#ver1[@]}; i++)); do + [[ -z ${ver2[i]} ]] && ver2[i]=0 + ((10#${ver1[i]} > 10#${ver2[i]})) && return 0 + ((10#${ver1[i]} < 10#${ver2[i]})) && die_pkg "${pkg}" "${need}" + done +} +### Check if required binaries are installed at appropriate versions +check_tools(){ + if [ -z "${BASH_VERSINFO[0]}" ] \ + || [ "${BASH_VERSINFO[0]}" -lt "${MIN_BASH_VERSION}" ]; then + die_pkg "bash" "${MIN_BASH_VERSION}" + fi + for cmd in "$@"; do + case $cmd in + buildx) + docker buildx version >/dev/null 2>&1 || die "Error: buildx not found" + version=$(docker buildx version 2>/dev/null | grep -o 'v[0-9.]*' | sed 's/v//') + check_version "buildx" "${version}" "${MIN_BUILDX_VERSION}" + ;; + docker) + command -v docker >/dev/null || die "Error: docker not found" + version=$(docker version -f '{{ .Server.Version }}') + check_version "docker" "${version}" "${MIN_DOCKER_VERSION}" + ;; + esac + done +} +check_tools docker buildx; +docker info -f '{{ .DriverStatus }}' \ + | grep "io.containerd.snapshotter.v1" >/dev/null \ +|| die "Error: Docker Engine is not using containerd for image storage" +