From 7c38e1cd19ab7d0cbe88e051004edf9483a01fb9 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 12 Aug 2026 16:23:34 +0200 Subject: [PATCH 1/5] fix(cli): exit with non-zero status code when a command fails main() logged errors returned by cmd.Execute but always exited with status 0, so failures of client commands (e.g. 'nuts status' against a node that is down) were invisible to scripts and to the Docker healthcheck. The server command is unaffected: it reports startup errors via logrus.Fatal, which already exits 1. Assisted-by: AI --- main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/main.go b/main.go index e913009eef..f05049231a 100644 --- a/main.go +++ b/main.go @@ -36,5 +36,6 @@ func main() { err := cmd.Execute(ctx, cmd.CreateSystem(cancelNotify)) if err != nil { logrus.Error(err) + os.Exit(1) } } From 783fea759df8e58504084074d2bc64f0170c0e6e Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 12 Aug 2026 16:23:34 +0200 Subject: [PATCH 2/5] build(docker): switch runtime image to distroless static The alpine-based runtime image accumulated known-fixed CVEs between releases (openssl, musl, zlib) because pre-installed packages were never upgraded and the image is only rebuilt on release. The nuts binary is pure static Go and only needed alpine for tzdata and curl: distroless static ships CA certificates and tzdata, and the curl-based healthcheck is replaced by the existing 'nuts status' client command, which probes the internal API without needing a shell. Assisted-by: AI --- Dockerfile | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9fc2f4aabc..354b692172 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,18 +20,14 @@ RUN go mod download && go mod verify COPY . . RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s -X 'github.com/nuts-foundation/nuts-node/core.GitCommit=${GIT_COMMIT}' -X 'github.com/nuts-foundation/nuts-node/core.GitBranch=${GIT_BRANCH}' -X 'github.com/nuts-foundation/nuts-node/core.GitVersion=${GIT_VERSION}'" -o /opt/nuts/nuts -# alpine -FROM alpine:3.24.1 -RUN apk update \ - && apk add --no-cache \ - tzdata \ - curl +# distroless static: contains CA certificates and tzdata, but no shell or package manager +FROM gcr.io/distroless/static-debian13:latest COPY --from=builder /opt/nuts/nuts /usr/bin/nuts +# exec form (no shell in this image); 'nuts status' GETs the internal API on localhost:8081 HEALTHCHECK --start-period=30s --timeout=5s --interval=10s \ - CMD curl -f http://localhost:8081/status || exit 1 + CMD ["/usr/bin/nuts", "status"] -RUN adduser -D -H -u 18081 nuts-usr USER 18081:18081 WORKDIR /nuts From 4dd07d86221866115ead674f249addbdf12ae5f8 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 12 Aug 2026 16:57:57 +0200 Subject: [PATCH 3/5] build(docker): fix CopyIgnoredFile check warning The '.*' pattern in .dockerignore also matches the literal path '.', so BuildKit's CopyIgnoredFile check flagged 'COPY . .' as copying an excluded file. '.?*' requires at least one character after the dot, which excludes the same set of dotfiles without matching the context root. Assisted-by: AI --- .dockerignore | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index 3907951d33..2f1a5eae1a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,7 @@ -# Ignore everything that starts with a dot -.* +# Ignore everything that starts with a dot ('.?*' instead of '.*' so the +# pattern does not match the build context root '.' itself, which trips +# the CopyIgnoredFile check on 'COPY . .') +.?* # Readme files and docs README.rst README_template.rst From e1339ab3cfa824a68194cfda1bac23a674f8e1bc Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Wed, 12 Aug 2026 17:07:21 +0200 Subject: [PATCH 4/5] build(docker): pin distroless base image by digest Distroless publishes no version tags, only 'latest' and variant tags, so pinning means pinning the multi-arch index digest. Dependabot's docker ecosystem (already configured) keeps the digest updated via weekly PRs, so base image updates become explicit and reviewable instead of implicit at build time. Assisted-by: AI --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 354b692172..5cc120af72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ COPY . . RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s -X 'github.com/nuts-foundation/nuts-node/core.GitCommit=${GIT_COMMIT}' -X 'github.com/nuts-foundation/nuts-node/core.GitBranch=${GIT_BRANCH}' -X 'github.com/nuts-foundation/nuts-node/core.GitVersion=${GIT_VERSION}'" -o /opt/nuts/nuts # distroless static: contains CA certificates and tzdata, but no shell or package manager -FROM gcr.io/distroless/static-debian13:latest +FROM gcr.io/distroless/static-debian13:latest@sha256:9197324ba51d9cd071af8505989365c006adf9d6d2067eada25aef00abbb5278 COPY --from=builder /opt/nuts/nuts /usr/bin/nuts # exec form (no shell in this image); 'nuts status' GETs the internal API on localhost:8081 From c12f4c761634447eb1346c00459dbbb5f246b728 Mon Sep 17 00:00:00 2001 From: Steven van der Vegt Date: Fri, 14 Aug 2026 14:18:19 +0200 Subject: [PATCH 5/5] test(e2e): stop exec'ing curl and rm inside the nuts-node container The distroless runtime image contains no shell or userland, so tests can no longer exec curl or rm inside the node container. Deleting connections.db now happens on the host (the data dirs are bind mounts and the services run as the host user), and HTTP calls that need the compose network run in a dedicated curlimages/curl helper service, gated behind the 'tools' profile so 'up' does not start it. The rfc002 helper mounts nodeB's client certificate to keep acting as nodeB. Assisted-by: AI --- e2e-tests/nuts-network/private-transactions/run-test.sh | 7 ++++--- e2e-tests/oauth-flow/openid4vp/do-test.sh | 3 ++- e2e-tests/oauth-flow/openid4vp/docker-compose.yml | 5 +++++ e2e-tests/oauth-flow/rfc002/docker-compose.yml | 7 +++++++ e2e-tests/oauth-flow/rfc002/run-test.sh | 3 ++- e2e-tests/oauth-flow/rfc021/do-test.sh | 3 ++- e2e-tests/oauth-flow/rfc021/docker-compose.yml | 7 ++++++- e2e-tests/openid4vci/network-issuance/run-test.sh | 7 ++++--- 8 files changed, 32 insertions(+), 10 deletions(-) diff --git a/e2e-tests/nuts-network/private-transactions/run-test.sh b/e2e-tests/nuts-network/private-transactions/run-test.sh index 6676fe73ee..1ec2b91ff0 100755 --- a/e2e-tests/nuts-network/private-transactions/run-test.sh +++ b/e2e-tests/nuts-network/private-transactions/run-test.sh @@ -57,9 +57,10 @@ echo "Restarting with NodeDID set..." echo "------------------------------------" # Start without bootstrap node, to enforce authenticated, discovered connections (required for private transactions) export BOOTSTRAP_NODES= -# Delete nodes' address books to avoid persisting initial "new node" delay, allowing to connect to each other immediately -docker compose exec nodeA rm -f /opt/nuts/data/network/connections.db -docker compose exec nodeB rm -f /opt/nuts/data/network/connections.db +# Delete nodes' address books to avoid persisting initial "new node" delay, allowing to connect to each other immediately. +# Deleted from the host (data dirs are bind mounts): the distroless image contains no 'rm'. +rm -f ./node-A/data/network/connections.db +rm -f ./node-B/data/network/connections.db docker compose stop docker compose up --wait diff --git a/e2e-tests/oauth-flow/openid4vp/do-test.sh b/e2e-tests/oauth-flow/openid4vp/do-test.sh index db68dc0115..4f43fb2eed 100755 --- a/e2e-tests/oauth-flow/openid4vp/do-test.sh +++ b/e2e-tests/oauth-flow/openid4vp/do-test.sh @@ -154,7 +154,8 @@ DPOP=$(cat ./node-B/dpop.txt) echo "------------------------------------" echo "Retrieving data..." echo "------------------------------------" -RESPONSE=$(docker compose exec nodeB-backend curl http://resource:80/resource -H "Authorization: DPoP $ACCESS_TOKEN" -H "DPoP: $DPOP" -v) +# curl runs in a dedicated helper container on the compose network (the nuts-node image contains no curl) +RESPONSE=$(docker compose run --rm curl http://resource:80/resource -H "Authorization: DPoP $ACCESS_TOKEN" -H "DPoP: $DPOP" -v) if echo $RESPONSE | grep -q "OK"; then echo "success!" else diff --git a/e2e-tests/oauth-flow/openid4vp/docker-compose.yml b/e2e-tests/oauth-flow/openid4vp/docker-compose.yml index 1540010e7e..2dc5076a8a 100644 --- a/e2e-tests/oauth-flow/openid4vp/docker-compose.yml +++ b/e2e-tests/oauth-flow/openid4vp/docker-compose.yml @@ -56,3 +56,8 @@ services: - "../../scripts/oauth2.js:/etc/nginx/oauth2.js:ro" depends_on: - nodeA-backend + # helper for do-test.sh, invoked via 'docker compose run --rm curl' (the nuts-node image contains no curl); + # the 'tools' profile keeps it from being started by 'docker compose up' + curl: + image: curlimages/curl:8.18.0 + profiles: [ tools ] diff --git a/e2e-tests/oauth-flow/rfc002/docker-compose.yml b/e2e-tests/oauth-flow/rfc002/docker-compose.yml index 8639312014..397fdeb8dc 100644 --- a/e2e-tests/oauth-flow/rfc002/docker-compose.yml +++ b/e2e-tests/oauth-flow/rfc002/docker-compose.yml @@ -35,3 +35,10 @@ services: - "../../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" healthcheck: interval: 1s # Make test run quicker by checking health status more often + # helper for run-test.sh, invoked via 'docker compose run --rm curl' (the nuts-node image contains no curl); + # the 'tools' profile keeps it from being started by 'docker compose up' + curl: + image: curlimages/curl:8.18.0 + profiles: [ tools ] + volumes: + - "../../tls-certs/nodeB-certificate.pem:/certs/nodeB-client.pem:ro" diff --git a/e2e-tests/oauth-flow/rfc002/run-test.sh b/e2e-tests/oauth-flow/rfc002/run-test.sh index 9d0886de7e..0708c2f96f 100755 --- a/e2e-tests/oauth-flow/rfc002/run-test.sh +++ b/e2e-tests/oauth-flow/rfc002/run-test.sh @@ -147,7 +147,8 @@ echo "------------------------------------" echo "Retrieving data..." echo "------------------------------------" -RESPONSE=$(docker compose exec nodeB curl --insecure --cert /opt/nuts/certificate-and-key.pem --key /opt/nuts/certificate-and-key.pem https://nodeA:443/ping -H "Authorization: bearer $(cat ./node-B/data/accesstoken.txt)" -v) +# curl runs in a dedicated helper container on the compose network, using nodeB's client cert to act as nodeB +RESPONSE=$(docker compose run --rm curl --insecure --cert /certs/nodeB-client.pem --key /certs/nodeB-client.pem https://nodeA:443/ping -H "Authorization: bearer $(cat ./node-B/data/accesstoken.txt)" -v) if echo $RESPONSE | grep -q "pong"; then echo "success!" else diff --git a/e2e-tests/oauth-flow/rfc021/do-test.sh b/e2e-tests/oauth-flow/rfc021/do-test.sh index 04fee13675..268d3791d8 100755 --- a/e2e-tests/oauth-flow/rfc021/do-test.sh +++ b/e2e-tests/oauth-flow/rfc021/do-test.sh @@ -69,7 +69,8 @@ fi STATUS_LIST_CREDENTIAL=$(echo $VENDOR_B_CREDENTIAL | jq -r .credentialStatus.statusListCredential) echo "Status list credential: $STATUS_LIST_CREDENTIAL" # Get status list credential -RESPONSE=$($db_dc exec nodeB-backend curl -s -k $STATUS_LIST_CREDENTIAL) +# curl runs in a dedicated helper container on the compose network (the nuts-node image contains no curl) +RESPONSE=$($db_dc run --rm curl -s -k $STATUS_LIST_CREDENTIAL) # Check response HTTP 200 OK if echo $RESPONSE | grep -q "\"id\":\"$STATUS_LIST_CREDENTIAL\"" ; then echo "Status list credential retrieved" diff --git a/e2e-tests/oauth-flow/rfc021/docker-compose.yml b/e2e-tests/oauth-flow/rfc021/docker-compose.yml index 36543dd3ea..36525f1ac2 100644 --- a/e2e-tests/oauth-flow/rfc021/docker-compose.yml +++ b/e2e-tests/oauth-flow/rfc021/docker-compose.yml @@ -53,4 +53,9 @@ services: - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/server.pem:ro" - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/key.pem:ro" - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" - - "./node-B/html:/etc/nginx/html:ro" \ No newline at end of file + - "./node-B/html:/etc/nginx/html:ro" + # helper for do-test.sh, invoked via 'docker compose run --rm curl' (the nuts-node image contains no curl); + # the 'tools' profile keeps it from being started by 'docker compose up' + curl: + image: curlimages/curl:8.18.0 + profiles: [ tools ] diff --git a/e2e-tests/openid4vci/network-issuance/run-test.sh b/e2e-tests/openid4vci/network-issuance/run-test.sh index fbc77782a6..9a2ae112bb 100755 --- a/e2e-tests/openid4vci/network-issuance/run-test.sh +++ b/e2e-tests/openid4vci/network-issuance/run-test.sh @@ -39,9 +39,10 @@ echo "Restarting with NodeDID set..." echo "------------------------------------" # Start without bootstrap node, to enforce authenticated, discovered connections (required for private transactions) export BOOTSTRAP_NODES= -# Delete nodes' address books to avoid persisting initial "new node" delay, allowing to connect to each other immediately -docker compose exec nodeA-backend rm -f /opt/nuts/data/network/connections.db -docker compose exec nodeB-backend rm -f /opt/nuts/data/network/connections.db +# Delete nodes' address books to avoid persisting initial "new node" delay, allowing to connect to each other immediately. +# Deleted from the host (data dirs are bind mounts): the distroless image contains no 'rm'. +rm -f ./node-A/data/network/connections.db +rm -f ./node-B/data/network/connections.db docker compose stop docker compose up --wait