diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 6734c0e..a7120e7 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -4,39 +4,43 @@ on: pull_request: paths: - "**/Dockerfile" - - "**/entrypoint.py" + - "**/entrypoint*" - "**/PLATFORMS" - "tests/**" - "tools/genmatrix.js" - ".github/workflows/build-ci.yml" +permissions: + contents: read + pull-requests: read + jobs: gen-matrix: name: generate-matrix runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 - - - name: Get changed files - id: get-changed-files - uses: jitterbit/get-changed-files@v1 - with: - format: 'json' + uses: actions/checkout@v4 - name: Generate testing matrix - uses: actions/github-script@v4.1 + uses: actions/github-script@v7 id: generator with: - github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const script = require(`${process.env.GITHUB_WORKSPACE}/tools/genmatrix.js`) - return script(process.env.GITHUB_WORKSPACE, ${{ steps.get-changed-files.outputs.all }}); + // Read changed files from the API rather than a third-party + // action, so the build list depends on nothing we don't control. + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + const script = require(`${process.env.GITHUB_WORKSPACE}/tools/genmatrix.js`); + return script(process.env.GITHUB_WORKSPACE, files.map(file => file.filename)); outputs: matrix: ${{ steps.generator.outputs.result }} build: - if: ${{ fromJson(needs.gen-matrix.outputs.matrix) }} + if: ${{ needs.gen-matrix.outputs.matrix != 'null' }} needs: gen-matrix name: build env: @@ -50,17 +54,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 - name: Build image - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: builder: ${{ steps.buildx.outputs.name }} push: false diff --git a/.github/workflows/lint-docker.yml b/.github/workflows/lint-docker.yml new file mode 100644 index 0000000..db87fef --- /dev/null +++ b/.github/workflows/lint-docker.yml @@ -0,0 +1,39 @@ +name: lint-docker + +on: + push: + paths: + - "**/Dockerfile" + - ".github/workflows/lint-docker.yml" + pull_request: + paths: + - "**/Dockerfile" + - ".github/workflows/lint-docker.yml" + +permissions: + contents: read + +jobs: + lint-docker: + name: lint (dockerfile) + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Every Dockerfile in the tree, so a new version directory is linted + # without touching this workflow. Rule exceptions belong inline, next + # to the line they excuse. + - name: Run hadolint + run: | + status=0 + while IFS= read -r dockerfile; do + echo "==> ${dockerfile}" + docker run --rm -i hadolint/hadolint:v2.12.0 hadolint - \ + < "${dockerfile}" || status=1 + done < <(find . -name Dockerfile) + exit "${status}" + shell: bash diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 33db535..09da753 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -10,6 +10,9 @@ on: - "**/*.js" - ".github/workflows/lint-js.yml" +permissions: + contents: read + jobs: lint-js: name: lint (javascript) @@ -18,12 +21,16 @@ jobs: strategy: fail-fast: false steps: - - name: Install semistandard - run: | - npm install -g semistandard - - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + + - name: Set up node + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + + - name: Install semistandard + run: npm install -g semistandard - name: Run linter - run: find . -name "*.js" | xargs semistandard + run: find . -name "*.js" -exec semistandard {} + diff --git a/.github/workflows/lint-py.yml b/.github/workflows/lint-py.yml index 7c6f76f..1867a9f 100644 --- a/.github/workflows/lint-py.yml +++ b/.github/workflows/lint-py.yml @@ -4,12 +4,17 @@ on: push: paths: - "**/*.py" + - "pylintrc.tests" - ".github/workflows/lint-py.yml" pull_request: paths: - "**/*.py" + - "pylintrc.tests" - ".github/workflows/lint-py.yml" +permissions: + contents: read + jobs: lint-py: name: lint (python) @@ -18,17 +23,24 @@ jobs: strategy: fail-fast: false steps: + - name: Checkout + uses: actions/checkout@v4 + + # Runner system python is externally managed (PEP 668), pip cannot + # install into it. Provision our own interpreter instead. + - name: Set up python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Install pylint run: pip3 install pylint - - name: Checkout - uses: actions/checkout@v2 - - name: Run linter for images code - run: find . -name "*.py" ! -path "*/tests/*" | xargs pylint + run: find . -name "*.py" ! -path "*/tests/*" -exec pylint --rcfile=pylintrc.images {} + - name: Install pylint dependencies for tests run: pip3 install pytest - name: Run linter for tests code - run: find "./tests" -name "*.py" | xargs pylint --rcfile=pylintrc.tests + run: find "./tests" -name "*.py" -exec pylint --rcfile=pylintrc.tests {} + diff --git a/.github/workflows/lint-sh.yml b/.github/workflows/lint-sh.yml new file mode 100644 index 0000000..7ae35bd --- /dev/null +++ b/.github/workflows/lint-sh.yml @@ -0,0 +1,38 @@ +name: lint-sh + +on: + push: + paths: + - "**/*.sh" + - ".github/workflows/lint-sh.yml" + pull_request: + paths: + - "**/*.sh" + - ".github/workflows/lint-sh.yml" + +permissions: + contents: read + +jobs: + lint-sh: + name: lint (shell) + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + # No-op while the tree has no shell scripts; present so that adding one + # cannot land unlinted. + - name: Run shellcheck + run: | + mapfile -t scripts < <(find . -name "*.sh") + if [ "${#scripts[@]}" -eq 0 ]; then + echo "No shell scripts to lint." + exit 0 + fi + docker run --rm -v "${PWD}:/mnt" -w /mnt \ + koalaman/shellcheck:v0.9.0 "${scripts[@]}" + shell: bash diff --git a/1.14.5/bullseye/Dockerfile b/1.14.5/bookworm/Dockerfile similarity index 75% rename from 1.14.5/bullseye/Dockerfile rename to 1.14.5/bookworm/Dockerfile index 403bb97..366a5b4 100644 --- a/1.14.5/bullseye/Dockerfile +++ b/1.14.5/bookworm/Dockerfile @@ -1,4 +1,4 @@ -FROM debian:bullseye-slim AS verify +FROM debian:bookworm-slim AS verify WORKDIR /verify @@ -28,6 +28,9 @@ ARG DESCRIPTOR_PATH=dogecoin/contrib/gitian-descriptors/gitian-${RLS_OS}.yml ARG RLS_LOCATION=https://github.com/dogecoin/dogecoin/releases/download/v${RLS_VERSION} # install system requirements +# Versions are deliberately unpinned: Debian rotates point releases out of +# the archive, so a pin turns a security update into a build failure. +# hadolint ignore=DL3008 RUN apt-get update && apt-get install --no-install-recommends -y \ wget \ git \ @@ -60,7 +63,7 @@ RUN ARCHITECTURE=$(dpkg --print-architecture) \ && grep "${RLS_FILE_NAME}" SHASUMS | sha256sum -c \ && mv "${RLS_FILE_NAME}" dogecoin.tar.gz -FROM debian:bullseye-slim AS final +FROM debian:bookworm-slim AS final ENV USER=dogecoin ENV DATADIR=/${USER}/.dogecoin @@ -68,21 +71,29 @@ ENV DATADIR=/${USER}/.dogecoin # Root configuration to mimic user ENV HOME=/${USER} -RUN useradd ${USER} --home-dir ${HOME} +# Pin the uid. An unpinned useradd takes whatever is free, so a base image +# change would silently move ownership of bind-mounted wallet files. +RUN useradd ${USER} --uid 1000 --user-group --home-dir ${HOME} WORKDIR /tmp # Copy the downloaded binary from the verify stage COPY --from=verify /verify/dogecoin.tar.gz ./ -# Move downloaded binaries and man pages in the container system. -# Setuid on binaries with $USER rights, to limit root usage. +# Install the binaries root-owned and not setuid, so the unprivileged +# runtime user can neither replace them nor escalate through them. RUN tar -xvf dogecoin.tar.gz --strip-components=1 \ && cp bin/dogecoind bin/dogecoin-cli bin/dogecoin-tx /usr/local/bin/ \ - && chown ${USER}:${USER} /usr/local/bin/dogecoin* \ - && chmod 4555 /usr/local/bin/dogecoin* \ + && chown root:root /usr/local/bin/dogecoin* \ + && chmod 0555 /usr/local/bin/dogecoin* \ && rm -rf -- * +# Group-own the home and datadir by root(0) and grant group write, so an +# operator overriding the uid (--user 1001:0) keeps write access. +RUN mkdir -p ${DATADIR} \ + && chown -R 1000:0 ${HOME} \ + && chmod -R g+rwX ${HOME} + WORKDIR ${HOME} # P2P network (mainnet, testnet & regnet respectively) @@ -94,12 +105,19 @@ EXPOSE 22555 44555 18332 VOLUME ["/dogecoin/.dogecoin"] # Dependencies install +# hadolint ignore=DL3008 RUN apt-get update && apt-get install --no-install-recommends -y \ python3 \ && rm -rf /var/lib/apt/lists/* COPY entrypoint.py /usr/local/bin/entrypoint.py -RUN chmod 500 /usr/local/bin/entrypoint.py +# World-readable and executable: the entrypoint now runs as the +# unprivileged user, so a root-only mode would make the image unstartable. +RUN chmod 0555 /usr/local/bin/entrypoint.py + +# Numeric, so that runAsNonRoot admission can verify it without running +# the image. Never root at runtime. +USER 1000:1000 ENTRYPOINT ["entrypoint.py"] CMD ["dogecoind"] diff --git a/1.14.5/bullseye/PLATFORMS b/1.14.5/bookworm/PLATFORMS similarity index 100% rename from 1.14.5/bullseye/PLATFORMS rename to 1.14.5/bookworm/PLATFORMS diff --git a/1.14.5/bullseye/entrypoint.py b/1.14.5/bookworm/entrypoint.py similarity index 78% rename from 1.14.5/bullseye/entrypoint.py rename to 1.14.5/bookworm/entrypoint.py index ce56474..a118e89 100755 --- a/1.14.5/bullseye/entrypoint.py +++ b/1.14.5/bookworm/entrypoint.py @@ -4,7 +4,6 @@ """ import argparse import os -import pwd import shutil import sys import subprocess @@ -71,8 +70,10 @@ def create_datadir(): """ Create data directory used by dogecoin daemon. - Create manually the directory while root at container creation, - root rights needed to create folder with host volume. + Runs unprivileged and never chowns, so a bind-mounted host directory + keeps the ownership the operator gave it. + + Returns True when the directory exists and is writable. """ #Try to get datadir from argv parser = argparse.ArgumentParser(add_help=False) @@ -82,10 +83,21 @@ def create_datadir(): #Try to get datadir from environment datadir = argv.datadir or os.environ.get("DATADIR") - os.makedirs(datadir, exist_ok=True) + try: + os.makedirs(datadir, exist_ok=True) + except OSError as err: + print(f"{sys.argv[0]}: cannot create datadir {datadir}: {err}", + file=sys.stderr) + return False + + #An existing bind mount may belong to another uid entirely. + if not os.access(datadir, os.W_OK): + print(f"{sys.argv[0]}: datadir {datadir} is not writable by uid " + f"{os.getuid()}. Bind-mount a directory you own, or run " + "with --user $(id -u):$(id -g).", file=sys.stderr) + return False - user = os.environ["USER"] - subprocess.run(["chown", "-R", f"{user}:{user}", datadir], check=True) + return True def convert_env(executable): """ @@ -103,7 +115,8 @@ def convert_env(executable): corresponding option do not expect a value. """ man_options = executable_options(executable) - option_to_env = lambda opt_value : opt_value.upper().replace("-", "_") + def option_to_env(opt_value): + return opt_value.upper().replace("-", "_") cli_arguments = [] for option in man_options: @@ -119,22 +132,17 @@ def convert_env(executable): def run_executable(executable, executable_args): """ Run selected dogecoin executable with arguments from environment and - command line. Switch manually from root rights needed at startup - to unprivileged user. + command line. + + The container already runs as an unprivileged user, so there is no + privilege left to drop here. - Manually execve + setuid/setgid to run process as pid 1, - to manage a single process in a container & more predictive - signal handling. + Manually execve to run process as pid 1, to manage a single process + in a container & more predictive signal handling. """ if executable == "dogecoind": executable_args.append("-printtoconsole") - #Switch process from root to user. - #Equivalent to use gosu or su-exec - user_info = pwd.getpwnam(os.environ['USER']) - os.setgid(user_info.pw_gid) - os.setuid(user_info.pw_uid) - #Run container command return execute(executable, executable_args) @@ -151,7 +159,8 @@ def main(): if executable not in CLI_EXECUTABLES: return execute(executable, sys.argv[1:]) - create_datadir() + if not create_datadir(): + return 1 executable_args = convert_env(executable) executable_args += sys.argv[1:] diff --git a/1.14.9/bookworm/Dockerfile b/1.14.9/bookworm/Dockerfile new file mode 100644 index 0000000..de1f9c8 --- /dev/null +++ b/1.14.9/bookworm/Dockerfile @@ -0,0 +1,129 @@ +FROM debian:bookworm-slim AS verify + +WORKDIR /verify + +# github repository locations +ARG REPO_DOGECOIN_CORE=https://github.com/dogecoin/dogecoin.git + +# Specify release variables +ARG RLS_VERSION=1.14.9 +ARG RLS_OS=linux +ARG RLS_LIB=gnu +ARG RLS_ARCH= + +# Fingerprint of the key that signed this release's SHA256SUMS, pinned so a +# signature from any other key in the keyring is rejected. The key is shipped +# in contrib/gitian-keys at the release tag. +ARG RLS_SIGNER=DC6EF4A8BF9F1B1E4DE1EE522D3A345B98D0DC1F + +# configure the shell before the first RUN +SHELL ["/bin/bash", "-ex", "-o", "pipefail", "-c"] + +# pin known sha256sums, checked independently of the signed manifest so that +# swapping both the release asset and its signature is still not enough +RUN echo 6928c895a20d0bcb6d5c7dcec753d35c884a471aaf8ad4242a89a96acb4f2985 dogecoin-1.14.9-aarch64-linux-gnu.tar.gz > SHASUMS \ + && echo 311fe8aee346d3f9a00c0a8ac594224ca3bfa297fec8a5fae20bb70f28961421 dogecoin-1.14.9-arm-linux-gnueabihf.tar.gz >> SHASUMS \ + && echo b8e1846a0979f369042dcf14435dfcea704b1456e34bc9657f0829d9eac0d3b0 dogecoin-1.14.9-i686-pc-linux-gnu.tar.gz >> SHASUMS \ + && echo 4f227117b411a7c98622c970986e27bcfc3f547a72bef65e7d9e82989175d4f8 dogecoin-1.14.9-x86_64-linux-gnu.tar.gz >> SHASUMS + +# static derived variables +ARG RLS_LOCATION=https://github.com/dogecoin/dogecoin/releases/download/v${RLS_VERSION} + +# install system requirements +# Versions are deliberately unpinned: Debian rotates point releases out of +# the archive, so a pin turns a security update into a build failure. +# hadolint ignore=DL3008 +RUN apt-get update && apt-get install --no-install-recommends -y \ + wget \ + git \ + gpg \ + gpg-agent \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# import the maintainer keyring as published at the release tag +RUN git clone --depth 1 -b v${RLS_VERSION} ${REPO_DOGECOIN_CORE} dogecoin \ + && find dogecoin/contrib/gitian-keys -name "*.pgp" -exec gpg --import {} + + +# Determine architecture, download the release binary, and verify it against +# the maintainer-signed manifest and against our own pinned sums. +# +# Unlike 1.14.5 this does not verify via gitian.sigs: no signer of +# 1.14.9-linux has a key in contrib/gitian-keys, so every signature there +# resolves to BAD SIGNATURE and there is nothing to select a random signer +# from. The release SHA256SUMS is signed directly by a maintainer key that +# is in the keyring, which is a stronger claim than a random third-party +# rebuild anyway. See the linked issue about restoring gitian coverage. +RUN ARCHITECTURE=$(dpkg --print-architecture) \ + && if [ "${ARCHITECTURE}" = "amd64" ]; then RLS_ARCH=x86_64 ; fi \ + && if [ "${ARCHITECTURE}" = "arm64" ]; then RLS_ARCH=aarch64; fi \ + && if [ "${ARCHITECTURE}" = "armhf" ]; then RLS_ARCH=arm && RLS_LIB=gnueabihf; fi \ + && if [ "${ARCHITECTURE}" = "i386" ]; then RLS_ARCH=i686-pc; fi \ + && if [ "${RLS_ARCH}" = "" ]; then echo "Could not determine architecture" >&2; exit 1; fi \ + && RLS_FILE_NAME="dogecoin-${RLS_VERSION}-${RLS_ARCH}-${RLS_OS}-${RLS_LIB}.tar.gz" \ + && wget -q "${RLS_LOCATION}/SHA256SUMS.asc" \ + && wget -q "${RLS_LOCATION}/${RLS_FILE_NAME}" \ + && gpg --status-fd 1 --output SHA256SUMS --decrypt SHA256SUMS.asc \ + | grep -q "^\[GNUPG:\] VALIDSIG ${RLS_SIGNER} " \ + && grep "${RLS_FILE_NAME}" SHA256SUMS | sha256sum -c \ + && grep "${RLS_FILE_NAME}" SHASUMS | sha256sum -c \ + && mv "${RLS_FILE_NAME}" dogecoin.tar.gz + +FROM debian:bookworm-slim AS final + +ENV USER=dogecoin +ENV DATADIR=/${USER}/.dogecoin + +# Root configuration to mimic user +ENV HOME=/${USER} + +# Pin the uid. An unpinned useradd takes whatever is free, so a base image +# change would silently move ownership of bind-mounted wallet files. +RUN useradd ${USER} --uid 1000 --user-group --home-dir ${HOME} + +WORKDIR /tmp + +# Copy the downloaded binary from the verify stage +COPY --from=verify /verify/dogecoin.tar.gz ./ + +# Install the binaries root-owned and not setuid, so the unprivileged +# runtime user can neither replace them nor escalate through them. +RUN tar -xvf dogecoin.tar.gz --strip-components=1 \ + && cp bin/dogecoind bin/dogecoin-cli bin/dogecoin-tx /usr/local/bin/ \ + && chown root:root /usr/local/bin/dogecoin* \ + && chmod 0555 /usr/local/bin/dogecoin* \ + && rm -rf -- * + +# Group-own the home and datadir by root(0) and grant group write, so an +# operator overriding the uid (--user 1001:0) keeps write access. +RUN mkdir -p ${DATADIR} \ + && chown -R 1000:0 ${HOME} \ + && chmod -R g+rwX ${HOME} + +WORKDIR ${HOME} + +# P2P network (mainnet, testnet & regnet respectively) +EXPOSE 22556 44556 18444 + +# RPC interface (mainnet, testnet & regnet respectively) +EXPOSE 22555 44555 18332 + +VOLUME ["/dogecoin/.dogecoin"] + +# Dependencies install +# hadolint ignore=DL3008 +RUN apt-get update && apt-get install --no-install-recommends -y \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.py /usr/local/bin/entrypoint.py +# World-readable and executable: the entrypoint now runs as the +# unprivileged user, so a root-only mode would make the image unstartable. +RUN chmod 0555 /usr/local/bin/entrypoint.py + +# Numeric, so that runAsNonRoot admission can verify it without running +# the image. Never root at runtime. +USER 1000:1000 + +ENTRYPOINT ["entrypoint.py"] +CMD ["dogecoind"] diff --git a/1.14.9/bookworm/PLATFORMS b/1.14.9/bookworm/PLATFORMS new file mode 100644 index 0000000..743dafe --- /dev/null +++ b/1.14.9/bookworm/PLATFORMS @@ -0,0 +1,4 @@ +linux/amd64 +linux/arm64 +linux/arm/v7 +linux/386 diff --git a/1.14.9/bookworm/entrypoint.py b/1.14.9/bookworm/entrypoint.py new file mode 100755 index 0000000..a118e89 --- /dev/null +++ b/1.14.9/bookworm/entrypoint.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" + Docker entrypoint for Dogecoin Core +""" +import argparse +import os +import shutil +import sys +import subprocess + +CLI_EXECUTABLES = [ + "dogecoind", + "dogecoin-cli", + "dogecoin-tx", + ] + +def execute(executable, args): + """ + Run container command with execve(2). Use manually execve + to run the process as same pid and avoid to fork a child. + """ + executable_path = shutil.which(executable) + + if executable_path is None: + print(f"{sys.argv[0]}: {executable} not found.", file=sys.stderr) + return 1 + + #Prepare execve args & launch container command + execve_args = [executable_path] + args + return os.execve(executable_path, execve_args, os.environ) + +def get_help(command_arguments): + """Call any dogecoin executable help menu, retrieve its options""" + #Prepare menu call & grep command to pipe in a shell + menu_command = " ".join(command_arguments) + grep_command = "grep -E '^ -[a-z]+'" + + #Return a list of raw options of `-help` output + return subprocess.check_output( + f"{menu_command} | {grep_command}", + shell=True + ).decode("utf8").splitlines() + +def executable_options(executable): + """ + Retrieve available options of a dogecoin executable using help menu. + + Call executable with `-help` flag and parse output to detect available + Dogecoin Core options. + """ + command_arguments = [executable, "-help"] + + #`-help-debug` display extra flag in help menu for dogecoind & qt + if executable == "dogecoind": + command_arguments.append("-help-debug") + + help_options = get_help(command_arguments) + + #Clean raw option from the menu, keeping only variable name. + #For example, convert ` -rpcpassword=` in `rpcpassword`. + options = [] + for option_entry in help_options: + cleaned_option = option_entry.strip().split("=")[0] + cleaned_option = cleaned_option.replace("-", "", 1) + options.append(cleaned_option) + + return options + +def create_datadir(): + """ + Create data directory used by dogecoin daemon. + + Runs unprivileged and never chowns, so a bind-mounted host directory + keeps the ownership the operator gave it. + + Returns True when the directory exists and is writable. + """ + #Try to get datadir from argv + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("-datadir", "--datadir") + argv, _ = parser.parse_known_args() + + #Try to get datadir from environment + datadir = argv.datadir or os.environ.get("DATADIR") + + try: + os.makedirs(datadir, exist_ok=True) + except OSError as err: + print(f"{sys.argv[0]}: cannot create datadir {datadir}: {err}", + file=sys.stderr) + return False + + #An existing bind mount may belong to another uid entirely. + if not os.access(datadir, os.W_OK): + print(f"{sys.argv[0]}: datadir {datadir} is not writable by uid " + f"{os.getuid()}. Bind-mount a directory you own, or run " + "with --user $(id -u):$(id -g).", file=sys.stderr) + return False + + return True + +def convert_env(executable): + """ + Convert existing environment variables into command line arguments, + remove it from the environment. + + Options from executable man pages are searched in the environment, + converting options in upper case and convert "-" to "_". + + Exemple: + -rpcuser is RPCUSER + -help-debug is HELP_DEBUG + + Environment variables can be used with an empty value if the + corresponding option do not expect a value. + """ + man_options = executable_options(executable) + def option_to_env(opt_value): + return opt_value.upper().replace("-", "_") + + cli_arguments = [] + for option in man_options: + env_option = os.environ.pop(option_to_env(option), None) + + if env_option is not None: + cli_option = "-" + option + cli_option += "=" + env_option if env_option else "" + cli_arguments.append(cli_option) + + return cli_arguments + +def run_executable(executable, executable_args): + """ + Run selected dogecoin executable with arguments from environment and + command line. + + The container already runs as an unprivileged user, so there is no + privilege left to drop here. + + Manually execve to run process as pid 1, to manage a single process + in a container & more predictive signal handling. + """ + if executable == "dogecoind": + executable_args.append("-printtoconsole") + + #Run container command + return execute(executable, executable_args) + +def main(): + """ + Main routine + """ + if sys.argv[1].startswith("-"): + executable = "dogecoind" + else: + executable = sys.argv.pop(1) + + #Container running arbitrary commands unrelated to dogecoin + if executable not in CLI_EXECUTABLES: + return execute(executable, sys.argv[1:]) + + if not create_datadir(): + return 1 + + executable_args = convert_env(executable) + executable_args += sys.argv[1:] + + return run_executable(executable, executable_args) + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pylintrc.images b/pylintrc.images new file mode 100644 index 0000000..77c98a2 --- /dev/null +++ b/pylintrc.images @@ -0,0 +1,3 @@ +[images-configuration] +disable = duplicate-code # each version ships a self-contained entrypoint, + # see #16 and #40 on templatization diff --git a/tests/integration/datadir.py b/tests/integration/datadir.py new file mode 100644 index 0000000..d0d7a7c --- /dev/null +++ b/tests/integration/datadir.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# Copyright (c) 2021 The Dogecoin Core developers +""" +Test datadir creation, and that files keep the operator's ownership +""" + +import contextlib +import os +import shutil +import stat +import tempfile + +from .framework.test_runner import TestRunner + +# Mount point used inside the container for the host directory under test. +# Not the default datadir, so that creation is actually exercised. +MOUNT_POINT = "/mnt/host" + +class DatadirTest(TestRunner): + """Datadir creation and runtime ownership test""" + + def run_test(self): + """Check runtime privileges, datadir creation and its ownership""" + runtime_uid = self.get_runtime_uid() + + if runtime_uid == 0: + raise AssertionError("container runs as root") + + # Default runtime user owns what it creates. + self.ensure_datadir_created(None, runtime_uid) + + # An operator overriding the uid keeps ownership of their own files, + # which is the whole point of not shipping setuid binaries. + self.ensure_datadir_created("1001:0", 1001) + + def get_runtime_uid(self): + """Return the uid the container runs as by default""" + result = self.run_command([], ["sh", "-c", "id -u"]) + return int(result.stdout.decode("utf-8").strip()) + + def ensure_datadir_created(self, user, expected_uid): + """ + Run a dogecoin executable with a datadir that does not exist yet and + assert the entrypoint created it as expected_uid. + """ + with self.host_directory() as host_dir: + datadir = f"{ MOUNT_POINT }/datadir" + + # `-?` makes the executable print help and exit, after the + # entrypoint has already created the datadir. + self.run_command([], ["dogecoin-cli", f"-datadir={ datadir }", "-?"], + user=user, + volumes=[f"{ host_dir }:{ MOUNT_POINT }"]) + + created = os.path.join(host_dir, "datadir") + + if not os.path.isdir(created): + raise AssertionError(f"entrypoint did not create { datadir }") + + owner = os.stat(created).st_uid + + if owner != expected_uid: + text = (f"datadir created by uid { owner }, " + f"expected { expected_uid }") + raise AssertionError(text) + + @staticmethod + @contextlib.contextmanager + def host_directory(): + """ + Provide a world-writable host directory for the container to write + into, and remove it afterwards regardless of who owns its contents. + """ + host_dir = tempfile.mkdtemp() + + try: + # The container writes as a uid unrelated to the test runner. + os.chmod(host_dir, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) + yield host_dir + finally: + shutil.rmtree(host_dir, ignore_errors=True) + +if __name__ == '__main__': + DatadirTest().main() diff --git a/tests/integration/files_metadata.py b/tests/integration/files_metadata.py new file mode 100644 index 0000000..94ea7ce --- /dev/null +++ b/tests/integration/files_metadata.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# Copyright (c) 2021 The Dogecoin Core developers +""" +Test ownership and permissions of the files installed by the Dockerfile +""" + +from .framework.test_runner import TestRunner + +# path -> (uid, gid, octal mode) +# +# Everything is root-owned and not writable by the runtime user, so a +# compromised daemon cannot rewrite the binaries it was started from. +# A setuid or setgid bit would appear here as a fourth octal digit. +EXPECTED_METADATA = { + "/usr/local/bin/dogecoind": (0, 0, "555"), + "/usr/local/bin/dogecoin-cli": (0, 0, "555"), + "/usr/local/bin/dogecoin-tx": (0, 0, "555"), + "/usr/local/bin/entrypoint.py": (0, 0, "555"), + } + +class FilesMetadataTest(TestRunner): + """Installed files metadata test""" + + def run_test(self): + """Stat every installed file and compare against expectations""" + paths = sorted(EXPECTED_METADATA) + stat_format = "stat -c '%n %u %g %a' " + " ".join(paths) + + result = self.run_command([], ["sh", "-c", stat_format]) + found = self.parse_stat(result.stdout) + + for path, expected in EXPECTED_METADATA.items(): + if path not in found: + raise AssertionError(f"{ path } is missing from the image") + + if found[path] != expected: + text = f"{ path }: expected { expected }, found { found[path] }" + raise AssertionError(text) + + self.ensure_no_special_bits(found) + + @staticmethod + def parse_stat(cmd_output): + """Turn stat output into a map of path -> (uid, gid, mode)""" + found = {} + + for line in cmd_output.decode("utf-8").splitlines(): + if not line.strip(): + continue + + name, uid, gid, mode = line.split() + found[name] = (int(uid), int(gid), mode) + + return found + + @staticmethod + def ensure_no_special_bits(found): + """Assert no file carries a setuid or setgid bit""" + for path, metadata in found.items(): + mode = metadata[2] + + if len(mode) > 3 and int(mode, 8) & 0o6000: + text = f"{ path } carries a setuid/setgid bit: mode { mode }" + raise AssertionError(text) + +if __name__ == '__main__': + FilesMetadataTest().main() diff --git a/tests/integration/framework/docker_runner.py b/tests/integration/framework/docker_runner.py index c46a535..cb80872 100644 --- a/tests/integration/framework/docker_runner.py +++ b/tests/integration/framework/docker_runner.py @@ -16,12 +16,23 @@ def __init__(self, platform, image, verbose): self.image = image self.verbose = verbose - def construct_docker_command(self, envs, args): + def construct_docker_command(self, envs, args, user=None, volumes=None): """ Construct a docker command with env and args + + Optionally override the runtime user, as "uid:gid", and bind-mount + volumes given as "source:destination" strings. """ command = ["docker", "run", "--platform", self.platform] + if user is not None: + command.append("--user") + command.append(user) + + for volume in volumes or []: + command.append("-v") + command.append(volume) + for env in envs: command.append("-e") command.append(env) @@ -33,12 +44,12 @@ def construct_docker_command(self, envs, args): return command - def run_interactive_command(self, envs, args): + def run_interactive_command(self, envs, args, user=None, volumes=None): """ Run our target docker image with a list of environment variables and a list of arguments """ - command = self.construct_docker_command(envs, args) + command = self.construct_docker_command(envs, args, user, volumes) if self.verbose: print(f"Running command: { ' '.join(command) }") diff --git a/tests/integration/framework/test_runner.py b/tests/integration/framework/test_runner.py index daa2906..0277161 100644 --- a/tests/integration/framework/test_runner.py +++ b/tests/integration/framework/test_runner.py @@ -25,7 +25,7 @@ def run_test(self): """Actual test, must be implemented by the final class""" raise NotImplementedError - def run_command(self, envs, args): + def run_command(self, envs, args, user=None, volumes=None): """Run a docker command with env and args""" assert self.options.platform is not None assert self.options.image is not None @@ -33,7 +33,7 @@ def run_command(self, envs, args): runner = DockerRunner(self.options.platform, self.options.image, self.options.verbose) - return runner.run_interactive_command(envs, args) + return runner.run_interactive_command(envs, args, user, volumes) def main(self): """main loop""" diff --git a/tests/integration_runner.py b/tests/integration_runner.py index 9506f5e..ab160c7 100644 --- a/tests/integration_runner.py +++ b/tests/integration_runner.py @@ -41,6 +41,8 @@ def run_test(self): #List of tests to run tests = [ [ "version", [ "--version", self.options.version ] ], + [ "files_metadata", [] ], + [ "datadir", [] ], ] for test in tests: