From ae83506ac45ad1dc44c38b89b191674c05915991 Mon Sep 17 00:00:00 2001 From: xanimo Date: Wed, 10 Jun 2026 17:44:40 -0700 Subject: [PATCH 1/7] fix pylint C3001: replace lambda assignment with def in entrypoint.py --- 1.14.5/bullseye/entrypoint.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/1.14.5/bullseye/entrypoint.py b/1.14.5/bullseye/entrypoint.py index ce56474..9b816f2 100755 --- a/1.14.5/bullseye/entrypoint.py +++ b/1.14.5/bullseye/entrypoint.py @@ -103,7 +103,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: From cd4988db6cd53c6ae4b1b064083a1e0bd07826f8 Mon Sep 17 00:00:00 2001 From: xanimo Date: Fri, 14 Aug 2026 18:02:44 -0700 Subject: [PATCH 2/7] ci: drop unmaintained action and bump the rest jitterbit/get-changed-files@v1 has been unmaintained since 2021 and is the only third party dep in the build matrix. reads the changed file list off the pulls api now instead. bumped checkout/github-script/qemu/buildx/build-push, added permissions blocks, added entrypoint* to the paths filter so a non python entrypoint still triggers a build. not a repair. build-ci last ran green 2026-06-26 and lint-py 2026-06-11, the pinned actions still work, they're just old and one is abandoned. genmatrix untouched. same matrix for a changed Dockerfile, null for an unrelated file, all builds for a ci change. both pylint runs 10.00/10. --- .github/workflows/build-ci.yml | 38 +++++++++++++++++++--------------- .github/workflows/lint-js.yml | 19 +++++++++++------ .github/workflows/lint-py.yml | 22 +++++++++++++++----- 3 files changed, 51 insertions(+), 28 deletions(-) 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-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..1c1504b 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 {} + - 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 {} + From 428ff54307a939e9a967954b38d499899f536e45 Mon Sep 17 00:00:00 2001 From: xanimo Date: Fri, 14 Aug 2026 18:05:29 -0700 Subject: [PATCH 3/7] qa: lint Dockerfiles and shell scripts hadolint and shellcheck run over the whole tree so a new version dir or a new script is covered without touching the workflow. closes the rest of #43. suppressed DL3008 inline on both apt-get calls instead of fixing it since debian rotates point releases out of the archive and pinning turns a security update into a build failure. hadolint v2.12.0 exits 0 on 1.14.5/bullseye, shellcheck path is a no-op until a script exists. --- .github/workflows/lint-docker.yml | 39 +++++++++++++++++++++++++++++++ .github/workflows/lint-sh.yml | 38 ++++++++++++++++++++++++++++++ 1.14.5/bullseye/Dockerfile | 4 ++++ 3 files changed, 81 insertions(+) create mode 100644 .github/workflows/lint-docker.yml create mode 100644 .github/workflows/lint-sh.yml 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-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/bullseye/Dockerfile index 403bb97..8c7a215 100644 --- a/1.14.5/bullseye/Dockerfile +++ b/1.14.5/bullseye/Dockerfile @@ -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 \ @@ -94,6 +97,7 @@ 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/* From f1efb71bd0010e67f5220af79e12c2d151895d23 Mon Sep 17 00:00:00 2001 From: xanimo Date: Fri, 14 Aug 2026 18:18:14 -0700 Subject: [PATCH 4/7] security: drop setuid and pin the uid binaries were installed setuid to a useradd assigned uid and entrypoint.py setuid()d to it, so everything written to a bind mount landed as that uid no matter who ran the container. --user couldn't override it since setuid() is EPERM for a non root caller. binaries are root:root 0555 now with no setuid bit, uid pinned so a base image change can't move it, USER 1000:1000 numeric so runAsNonRoot can check it without running the image. setuid and chown are gone from the entrypoint and the datadir is group owned by 0 with group write so --user 1001:0 keeps access. verified on amd64. binaries r-xr-xr-x root root, runtime uid 1000, dogecoind at --user 1001:1001 writes host files owned by 1001. integration suite passes, pylint 10.00/10, hadolint clean. --- 1.14.5/bullseye/Dockerfile | 26 ++++++++++++++++----- 1.14.5/bullseye/entrypoint.py | 44 +++++++++++++++++++++-------------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/1.14.5/bullseye/Dockerfile b/1.14.5/bullseye/Dockerfile index 8c7a215..7a86902 100644 --- a/1.14.5/bullseye/Dockerfile +++ b/1.14.5/bullseye/Dockerfile @@ -71,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) @@ -103,7 +111,13 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ && 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/entrypoint.py b/1.14.5/bullseye/entrypoint.py index 9b816f2..a118e89 100755 --- a/1.14.5/bullseye/entrypoint.py +++ b/1.14.5/bullseye/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): """ @@ -120,22 +132,17 @@ def option_to_env(opt_value): 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) @@ -152,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:] From 3f4b6543edbf9bbfe5182c13422a0f04e16d2d9b Mon Sep 17 00:00:00 2001 From: xanimo Date: Fri, 14 Aug 2026 18:25:45 -0700 Subject: [PATCH 5/7] qa: test installed file metadata and datadir ownership converts the pytest cases left in #33 and #50 to the integration framework. the originals assert mode 4555 and a fixed owner which is the defect not the behaviour. files_metadata stats every installed file and rejects any setuid or setgid bit. datadir checks the runtime user isn't root, that a missing datadir gets created, and that the creating uid is the operator's, once as the default user and once as --user 1001:0. framework gains optional user and volume args, nothing else uses them yet. these fail against the pre fix model rather than only passing against the new one. rebuilt with 4555 binaries and the old entrypoint gives "expected (0, 0, '555'), found (1000, 1000, '4555')" and "container runs as root" while the version test still passes. 3/3 green on the fixed image, pylint 10.00/10. --- tests/integration/datadir.py | 84 ++++++++++++++++++++ tests/integration/files_metadata.py | 67 ++++++++++++++++ tests/integration/framework/docker_runner.py | 17 +++- tests/integration/framework/test_runner.py | 4 +- tests/integration_runner.py | 2 + 5 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 tests/integration/datadir.py create mode 100644 tests/integration/files_metadata.py 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: From 3e1f8d80be527b92b8b636bc3c8d645872f98c8e Mon Sep 17 00:00:00 2001 From: xanimo Date: Fri, 14 Aug 2026 18:46:22 -0700 Subject: [PATCH 6/7] feat: add 1.14.9 on bookworm mirrors 1.14.5/bullseye with the same de-privileged model and the same entrypoint.py. verification differs. no signer of 1.14.9-linux has a key in contrib/gitian-keys so every signature there is BAD SIGNATURE and there's nothing to pick from. gates on the release SHA256SUMS instead, signed by a maintainer key that is in the keyring. fingerprint is pinned because gpg exits 0 on EXPKEYSIG and that key has since expired, so the exit code alone would accept a signature it never evaluated. pinned SHASUMS still checked separately. ca-certificates is explicit now, 1.14.5 got it transitively off ruby which this stage doesn't need. builds on all four declared platforms, 3/3 on amd64 and arm64, hadolint clean, pylint 10.00/10. build fails as intended when the fingerprint doesn't match. --- .github/workflows/lint-py.yml | 2 +- 1.14.9/bookworm/Dockerfile | 129 +++++++++++++++++++++++++ 1.14.9/bookworm/PLATFORMS | 4 + 1.14.9/bookworm/entrypoint.py | 171 ++++++++++++++++++++++++++++++++++ pylintrc.images | 3 + 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 1.14.9/bookworm/Dockerfile create mode 100644 1.14.9/bookworm/PLATFORMS create mode 100755 1.14.9/bookworm/entrypoint.py create mode 100644 pylintrc.images diff --git a/.github/workflows/lint-py.yml b/.github/workflows/lint-py.yml index 1c1504b..1867a9f 100644 --- a/.github/workflows/lint-py.yml +++ b/.github/workflows/lint-py.yml @@ -37,7 +37,7 @@ jobs: run: pip3 install pylint - name: Run linter for images code - run: find . -name "*.py" ! -path "*/tests/*" -exec pylint {} + + run: find . -name "*.py" ! -path "*/tests/*" -exec pylint --rcfile=pylintrc.images {} + - name: Install pylint dependencies for tests run: pip3 install pytest 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 From 208ac5a0a5f682a67f3b6e594029d7c37ea06717 Mon Sep 17 00:00:00 2001 From: xanimo Date: Fri, 4 Sep 2026 19:55:45 -0700 Subject: [PATCH 7/7] deps: move 1.14.5 to bookworm bullseye is past lts end so debian-security rotates packages out from under a stale index and the verify stage 404s on libperl5.32 deb11u5, which failed three of four platforms on ci twice in a row. copies the build to 1.14.5/bookworm with only the base image changed and drops the bullseye dir. fresh --no-cache build verifies against a gitian signer on bookworm ruby, 3/3 integration on amd64, hadolint clean. --- 1.14.5/{bullseye => bookworm}/Dockerfile | 4 ++-- 1.14.5/{bullseye => bookworm}/PLATFORMS | 0 1.14.5/{bullseye => bookworm}/entrypoint.py | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename 1.14.5/{bullseye => bookworm}/Dockerfile (98%) rename 1.14.5/{bullseye => bookworm}/PLATFORMS (100%) rename 1.14.5/{bullseye => bookworm}/entrypoint.py (100%) diff --git a/1.14.5/bullseye/Dockerfile b/1.14.5/bookworm/Dockerfile similarity index 98% rename from 1.14.5/bullseye/Dockerfile rename to 1.14.5/bookworm/Dockerfile index 7a86902..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 @@ -63,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 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 100% rename from 1.14.5/bullseye/entrypoint.py rename to 1.14.5/bookworm/entrypoint.py