From 7980e8e90a5774cdbf4d815b094bb4a4a4cdfb41 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 23 Aug 2025 12:57:51 -0400 Subject: [PATCH] Create hardened docker image (#955) * Create hardened docker image * Remove healthcheck that can't work * Pin action dependencies * Further cleanup and hardening * Fix broken hardened build * Enhance Dockerfile with healthcheck and optimizations Added healthcheck helper using a small Go file module and improved Dockerfile structure for readability. --------- Co-authored-by: Katos <7927609+katosdev@users.noreply.github.com> --- .../workflows/docker-publish-hardened.yaml | 208 ++++++++++++++++++ .../workflows/docker-publish-rootless.yaml | 4 +- .github/workflows/pull-requests.yaml | 5 +- Dockerfile.hardened | 136 ++++++++++++ docker-compose.yml | 2 +- 5 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/docker-publish-hardened.yaml create mode 100644 Dockerfile.hardened diff --git a/.github/workflows/docker-publish-hardened.yaml b/.github/workflows/docker-publish-hardened.yaml new file mode 100644 index 00000000..6efe8579 --- /dev/null +++ b/.github/workflows/docker-publish-hardened.yaml @@ -0,0 +1,208 @@ +name: Docker publish hardened + +on: + schedule: + - cron: '00 0 * * *' + push: + branches: [ "main" ] + paths: + - 'backend/**' + - 'frontend/**' + - 'Dockerfile.hardened' + - '.dockerignore' + - '.github/workflows/docker-publish-hardened.yaml' + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "main" ] + paths: + - 'backend/**' + - 'frontend/**' + - 'Dockerfile.hardened' + - '.dockerignore' + - '.github/workflows/docker-publish-hardened.yaml' + +permissions: + contents: read # Access to repository contents + packages: write # Write access for pushing to GHCR + id-token: write # Required for OIDC authentication (if used) + attestations: write # Required for signing and attestation (if needed) + +env: + DOCKERHUB_REPO: sysadminsmedia/homebox + GHCR_REPO: ghcr.io/${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + - linux/arm/v7 + + steps: + - name: Enable Debug Logs + run: echo "##[debug]Enabling debug logging" + env: + ACTIONS_RUNNER_DEBUG: true + ACTIONS_STEP_DEBUG: true + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Prepare + run: | + echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + branch=${{ github.event.pull_request.number || github.ref_name }} + echo "BRANCH=${branch//\//-}" >> $GITHUB_ENV + echo "DOCKERNAMES=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}" >> $GITHUB_ENV + if [[ "${{ github.event_name }}" != "schedule" ]] || [[ "${{ github.ref }}" != refs/tags/* ]]; then + echo "DOCKERNAMES=${{ env.GHCR_REPO }}" >> $GITHUB_ENV + fi + + - name: Docker meta + id: meta + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f + with: + images: | + name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }} + name=${{ env.GHCR_REPO }} + + - name: Login to Docker Hub + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 + if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')) + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 + with: + image: ghcr.io/amitie10g/binfmt:latest + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 + with: + driver-opts: | + image=ghcr.io/amitie10g/buildkit:master + + - name: Build and push by digest + id: build + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 + with: + context: . # Explicitly specify the build context + file: ./Dockerfile.hardened # Explicitly specify the Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,"name=${{ env.DOCKERNAMES }}",push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: type=registry,ref=ghcr.io/sysadminsmedia/devcache:${{ env.PLATFORM_PAIR }}-${{ env.BRANCH }}-hardened + cache-to: type=registry,ref=ghcr.io/sysadminsmedia/devcache:${{ env.PLATFORM_PAIR }}-${{ env.BRANCH }}-hardened,mode=max,ignore-error=true + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} + BUILD_TIME=${{ env.BUILD_TIME }} + provenance: true + sbom: true + annotations: ${{ steps.meta.outputs.annotations }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + needs: + - build + + steps: + - name: Download digests + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Login to Docker Hub + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 + if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')) + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 + with: + driver-opts: | + image=ghcr.io/amitie10g/buildkit:master + + - name: Docker meta + id: meta + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f + with: + images: | + name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }} + name=${{ env.GHCR_REPO }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=schedule,pattern=nightly + flavor: | + suffix=-hardened,onlatest=true + + - name: Create manifest list and push GHCR + id: push-ghcr + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_REPO }}@sha256:%s ' *) + + - name: Create manifest list and push Dockerhub + id: push-dockerhub + working-directory: /tmp/digests + if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')) + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *) diff --git a/.github/workflows/docker-publish-rootless.yaml b/.github/workflows/docker-publish-rootless.yaml index 41073bb2..0a3d9188 100644 --- a/.github/workflows/docker-publish-rootless.yaml +++ b/.github/workflows/docker-publish-rootless.yaml @@ -8,7 +8,7 @@ on: paths: - 'backend/**' - 'frontend/**' - - 'Dockerfile' + - 'Dockerfile.rootless' - '.dockerignore' - '.github/workflows/docker-publish-rootless.yaml' ignore: @@ -19,7 +19,7 @@ on: paths: - 'backend/**' - 'frontend/**' - - 'Dockerfile' + - 'Dockerfile.rootless' - '.dockerignore' - '.github/workflows/docker-publish-rootless.yaml' ignore: diff --git a/.github/workflows/pull-requests.yaml b/.github/workflows/pull-requests.yaml index 7112a6d6..fe4fe37b 100644 --- a/.github/workflows/pull-requests.yaml +++ b/.github/workflows/pull-requests.yaml @@ -9,7 +9,10 @@ on: paths: - 'backend/**' - 'frontend/**' - - '.github/workflows/**' + - '.github/workflows/partial-backend.yaml' + - '.github/workflows/partial-frontend.yaml' + - '.github/workflows/e2e-partial.yaml' + - '.github/workflows/pull-requests.yaml' jobs: backend-tests: diff --git a/Dockerfile.hardened b/Dockerfile.hardened new file mode 100644 index 00000000..1851a641 --- /dev/null +++ b/Dockerfile.hardened @@ -0,0 +1,136 @@ +# --------------------------------------- +# Node dependencies stage +# --------------------------------------- +FROM public.ecr.aws/docker/library/node:lts-alpine AS frontend-dependencies +WORKDIR /app + +# Install pnpm globally (caching layer) +RUN npm install -g pnpm + +# Copy package.json and lockfile to leverage caching +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +# --------------------------------------- +# Build Nuxt (frontend) stage +# --------------------------------------- +FROM public.ecr.aws/docker/library/node:lts-alpine AS frontend-builder +WORKDIR /app + +# Install pnpm globally again (it can reuse the cache if not changed) +RUN npm install -g pnpm + +# Copy over source files and node_modules from dependencies stage +COPY frontend . +COPY --from=frontend-dependencies /app/node_modules ./node_modules +RUN pnpm build + +# --------------------------------------- +# Go dependencies stage +# --------------------------------------- +FROM public.ecr.aws/docker/library/golang:alpine AS builder-dependencies +WORKDIR /go/src/app + +# Copy go.mod and go.sum for better caching +COPY ./backend/go.mod ./backend/go.sum ./ +RUN go mod download + +# --------------------------------------- +# Build API + healthcheck stage +# --------------------------------------- +FROM public.ecr.aws/docker/library/golang:alpine AS builder +ARG TARGETOS +ARG TARGETARCH +ARG BUILD_TIME +ARG COMMIT +ARG VERSION + +# Install necessary build tools +RUN apk update && \ + apk upgrade && \ + apk add --no-cache git build-base gcc g++ + +WORKDIR /go/src/app + +# Copy Go modules (from dependencies stage) and source code +COPY --from=builder-dependencies /go/pkg/mod /go/pkg/mod +COPY ./backend . + +# Clear old public files and copy new ones from frontend build +RUN rm -rf ./app/api/public +COPY --from=frontend-builder /app/.output/public ./app/api/static/public + +# Use cache for Go build artifacts to build Homebox API +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \ + -ldflags "-s -w -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.version=$VERSION" \ + -tags nodynamic -o /go/bin/api -v ./app/api/*.go + +RUN chmod +x /go/bin/api +RUN mkdir /app +RUN mkdir /data + +# ---------- Build static healthcheck helper ---------- +# A small Go program that GETs the status URL and exits 0 on 2xx. +RUN cat > /tmp/healthcheck.go <<'EOF' +package main +import ( + "fmt" + "net/http" + "os" + "time" +) +func main() { + url := "http://127.0.0.1:7745/api/v1/status" + if len(os.Args) > 1 { url = os.Args[1] } + c := &http.Client{ Timeout: 3 * time.Second } + resp, err := c.Get(url) + if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) } + resp.Body.Close() + if resp.StatusCode/100 != 2 { + fmt.Fprintln(os.Stderr, "unexpected status:", resp.StatusCode) + os.Exit(1) + } +} +EOF + +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -ldflags "-s -w" -o /go/bin/hc /tmp/healthcheck.go + +# --------------------------------------- +# Production stage +# --------------------------------------- +FROM gcr.io/distroless/static:nonroot +ENV HBOX_MODE=production +ENV HBOX_STORAGE_CONN_STRING=file:///?no_tmp_dir=true +ENV HBOX_STORAGE_PREFIX_PATH=data +ENV HBOX_DATABASE_SQLITE_PATH=/data/homebox.db?_pragma=busy_timeout=2000&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite + +# Create application directory and copy over built Go binary and assets +COPY --from=builder --chown=65532:65532 /app /app +COPY --from=builder --chown=65532:65532 --chmod=755 /go/bin/api /app +COPY --from=builder --chown=65532:65532 /data /data + +# Copy the healthcheck helper +COPY --from=builder --chown=65532:65532 --chmod=755 /go/bin/hc /app/healthcheck + +# Labels and configuration for the final image +LABEL Name=homebox Version=0.0.1 +LABEL org.opencontainers.image.source="https://github.com/sysadminsmedia/homebox" + +# Expose necessary ports for Homebox +EXPOSE 7745 +WORKDIR /app + +# Persist volume for data +VOLUME [ "/data" ] + +# Entrypoint and CMD +USER 65532 +ENTRYPOINT [ "/app/api" ] +CMD [ "/data/config.yml" ] + +# JSON exec-form healthcheck (no shell, no wget) +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["/app/healthcheck", "http://127.0.0.1:7745/api/v1/status"] diff --git a/docker-compose.yml b/docker-compose.yml index f576b709..e0240c29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: image: homebox build: context: . - dockerfile: ./Dockerfile.rootless + dockerfile: ./Dockerfile.hardened args: - COMMIT=head - BUILD_TIME=0001-01-01T00:00:00Z