diff --git a/.dockerignore b/.dockerignore index 8354639..eafc2a8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,28 +1,28 @@ -# Ignore version control files +# Version control / CI .git .gitignore .github -# Ignore documentation and metadata files +# Documentation and repository-only validation +docs/ +tests/ LICENSE +README.md *.md -# Ignore environment files +# Environment / local state .env .env.* -# Ignore Node.js modules -node_modules +# Dependency/build artifacts +node_modules/ npm-debug.log - -# Ignore temporary and cache files +vendor/ tmp/ cache/ - -# Ignore build artifacts and archives *.tar *.zip -# Ignore Docker Compose files +# Local compose files docker-compose.yml docker-compose*.yaml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..16c3c1d --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,137 @@ +name: Check + +on: + push: + branches: [ "main", "plan/**" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '23 3 * * 0' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: check-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + static: + name: Static and contract checks + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends shellcheck + + - name: Static checks + run: bash tests/static.sh + + - name: Install actionlint + env: + GOBIN: ${{ runner.temp }}/bin + run: | + mkdir -p "$GOBIN" + go install github.com/rhysd/actionlint/cmd/actionlint@latest + + - name: Lint workflows + run: "${{ runner.temp }}/bin/actionlint" + + runtime: + name: amd64 build and release gate + needs: static + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Dockerfile/BuildKit check + run: docker buildx build --check . + + - name: Build amd64 image + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64 + load: true + pull: true + push: false + tags: infocyph/apache:ci + cache-from: type=gha,scope=apache-check-amd64 + cache-to: type=gha,scope=apache-check-amd64,mode=max + + - name: Final amd64 release gate + run: bash tests/release-gate.sh infocyph/apache:ci + + - name: Image inventory + run: | + set -euo pipefail + docker image inspect infocyph/apache:ci --format 'Image size: {{.Size}} bytes' + docker run --rm --entrypoint sh infocyph/apache:ci -ec 'httpd -v; cat /etc/alpine-release; apk info | sort' + + - name: Vulnerability scan + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: infocyph/apache:ci + format: table + exit-code: '1' + ignore-unfixed: true + vuln-type: 'os,library' + severity: 'CRITICAL,HIGH' + + arm64: + name: arm64 build and startup smoke + needs: static + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build arm64 image + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/arm64 + load: true + pull: true + push: false + tags: infocyph/apache:ci-arm64 + cache-from: type=gha,scope=apache-check-arm64 + cache-to: type=gha,scope=apache-check-arm64,mode=max + + - name: arm64 command smoke + run: | + set -euo pipefail + docker run --rm --platform linux/arm64 --entrypoint sh infocyph/apache:ci-arm64 -ec ' + test "$(uname -m)" = aarch64 + httpd -t + httpd -v + command -v ab >/dev/null + command -v htpasswd >/dev/null + chromacat --version + ' diff --git a/.github/workflows/docker.publish.yml b/.github/workflows/docker.publish.yml index 3f03cd0..c1ee4b4 100644 --- a/.github/workflows/docker.publish.yml +++ b/.github/workflows/docker.publish.yml @@ -4,91 +4,374 @@ on: release: types: [published] schedule: - - cron: '0 0 * * */2' + - cron: '0 0 * * 0' + workflow_dispatch: + +concurrency: + group: docker-publish + cancel-in-progress: false jobs: - push_to_registries: - name: Push Docker image to Docker Hub and GHCR + publish: + name: Build, verify and publish Apache runs-on: ubuntu-latest + timeout-minutes: 70 permissions: contents: read packages: write attestations: write + artifact-metadata: write id-token: write + steps: - - name: Check out the repository - uses: actions/checkout@v4 + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false - - name: Set RELEASE_TAG environment variable + - name: Resolve stable release source env: GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + EVENT_RELEASE_TAG: ${{ github.event.release.tag_name }} + EVENT_RELEASE_DRAFT: ${{ github.event.release.draft }} + EVENT_RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} run: | - echo "Fetching latest release tag..." - RELEASE_TAG=$(gh release list --limit 1 --json tagName -q '.[0].tagName') - if [ -z "$RELEASE_TAG" ]; then - echo "No release found. Exiting." - exit 1 + set -euo pipefail + + if [[ "$EVENT_NAME" == release ]]; then + if [[ "$EVENT_RELEASE_DRAFT" == true || "$EVENT_RELEASE_PRERELEASE" == true ]]; then + echo 'Stable publish workflow refuses draft/prerelease releases.' >&2 + exit 1 + fi + RELEASE_TAG="$EVENT_RELEASE_TAG" + PUBLISH_RELEASE_TAG=true + VERIFY_TAG="$RELEASE_TAG" + else + release_json="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest")" + RELEASE_TAG="$(jq -r '.tag_name // empty' <<<"$release_json")" + release_draft="$(jq -r '.draft' <<<"$release_json")" + release_prerelease="$(jq -r '.prerelease' <<<"$release_json")" + [[ "$release_draft" == false && "$release_prerelease" == false ]] || { + echo 'Latest release is not a stable published release.' >&2 + exit 1 + } + PUBLISH_RELEASE_TAG=false + VERIFY_TAG=latest fi - echo "RELEASE_TAG=$RELEASE_TAG" >> $GITHUB_ENV - echo "Using release tag: $RELEASE_TAG" - - name: Set IMAGE_NAME environment variable - run: | - IMAGE_NAME=$(echo "$GITHUB_REPOSITORY" | cut -d'/' -f2 | sed 's/^docker-//') - echo "IMAGE_NAME=$IMAGE_NAME" >> $GITHUB_ENV - echo "Computed IMAGE_NAME: $IMAGE_NAME" + [[ -n "$RELEASE_TAG" && "$RELEASE_TAG" != latest ]] || { + echo 'No usable stable release tag found.' >&2 + exit 1 + } + + { + echo "RELEASE_TAG=$RELEASE_TAG" + echo "PUBLISH_RELEASE_TAG=$PUBLISH_RELEASE_TAG" + echo "VERIFY_TAG=$VERIFY_TAG" + } >> "$GITHUB_ENV" - - name: Check out the repo at the latest release tag - uses: actions/checkout@v4 + - name: Check out exact release source + uses: actions/checkout@v7 with: ref: ${{ env.RELEASE_TAG }} + persist-credentials: false + + - name: Capture source revision + run: echo "APACHE_SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Snapshot rolling upstream inputs + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + retry() { + local attempt=1 + until "$@"; do + (( attempt >= 5 )) && return 1 + sleep "$((attempt * 2))" + attempt=$((attempt + 1)) + done + } + + get_httpd_digest() { + local output digest + output="$(docker buildx imagetools inspect httpd:alpine 2>&1)" || { echo "$output" >&2; return 1; } + digest="$(printf '%s\n' "$output" | awk '$1 == "Digest:" {print $2; exit}')" + [[ -n "$digest" ]] || return 1 + printf '%s\n' "$digest" + } + + get_scriptomatic_sha() { + git ls-remote https://github.com/infocyph/Scriptomatic.git refs/heads/main | awk 'NF {print $1; exit}' + } + + get_toolset_release() { + gh api repos/infocyph/Toolset/releases/latest --jq '.tag_name // empty' + } + + get_installer_sha() { + local file + file="$(mktemp)" + curl --fail --silent --show-error --location \ + --retry 5 --retry-all-errors --retry-delay 2 \ + --connect-timeout 15 --max-time 120 \ + https://github.com/infocyph/Toolset/releases/latest/download/install.sh \ + -o "$file" + test -s "$file" + sha256sum "$file" | awk '{print $1}' + rm -f "$file" + } + + HTTPD_ALPINE_DIGEST="$(retry get_httpd_digest)" + SCRIPTOMATIC_MAIN_SHA="$(retry get_scriptomatic_sha)" + TOOLSET_RELEASE="$(retry get_toolset_release)" + TOOLSET_INSTALLER_SHA256="$(retry get_installer_sha)" + + [[ -n "$HTTPD_ALPINE_DIGEST" && -n "$SCRIPTOMATIC_MAIN_SHA" && -n "$TOOLSET_RELEASE" && -n "$TOOLSET_INSTALLER_SHA256" ]] + + { + echo "HTTPD_ALPINE_DIGEST=$HTTPD_ALPINE_DIGEST" + echo "SCRIPTOMATIC_MAIN_SHA=$SCRIPTOMATIC_MAIN_SHA" + echo "TOOLSET_RELEASE=$TOOLSET_RELEASE" + echo "TOOLSET_INSTALLER_SHA256=$TOOLSET_INSTALLER_SHA256" + } >> "$GITHUB_ENV" + + - name: Build fresh amd64 release candidate + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64 + load: true + pull: true + no-cache: true + push: false + tags: infocyph/apache:publish-candidate + cache-to: type=gha,scope=apache-publish-amd64,mode=max + + - name: Record candidate runtime resolution + env: + IMAGE: infocyph/apache:publish-candidate + run: | + set -euo pipefail + apache_version="$(docker run --rm --entrypoint httpd "$IMAGE" -v | sed -n 's#^Server version: Apache/\([^ ]*\).*#\1#p')" + alpine_version="$(docker run --rm --entrypoint cat "$IMAGE" /etc/alpine-release)" + chromacat_version="$(docker run --rm --entrypoint chromacat "$IMAGE" --version | head -n 1)" + banner_sha256="$(docker run --rm --entrypoint sha256sum "$IMAGE" /usr/local/bin/show-banner | awk '{print $1}')" + [[ -n "$apache_version" && -n "$alpine_version" && -n "$chromacat_version" && -n "$banner_sha256" ]] + { + echo "CANDIDATE_APACHE_VERSION=$apache_version" + echo "CANDIDATE_ALPINE_VERSION=$alpine_version" + echo "CANDIDATE_CHROMACAT_VERSION=$chromacat_version" + echo "CANDIDATE_BANNER_SHA256=$banner_sha256" + } >> "$GITHUB_ENV" + + - name: Final amd64 release-candidate gate + run: bash tests/release-gate.sh infocyph/apache:publish-candidate + + - name: Vulnerability scan release candidate + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: infocyph/apache:publish-candidate + format: table + exit-code: '1' + ignore-unfixed: true + vuln-type: 'os,library' + severity: 'CRITICAL,HIGH' + + - name: Build fresh arm64 release candidate + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/arm64 + load: true + pull: true + no-cache: true + push: false + tags: infocyph/apache:publish-candidate-arm64 + cache-to: type=gha,scope=apache-publish-arm64,mode=max + + - name: Final arm64 release-candidate gate + run: | + set -euo pipefail + docker run --rm --platform linux/arm64 --entrypoint sh infocyph/apache:publish-candidate-arm64 -ec ' + test "$(uname -m)" = aarch64 + httpd -t + httpd -v + command -v ab >/dev/null + command -v htpasswd >/dev/null + chromacat --version + ' + + - name: Revalidate rolling upstreams before publish + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + current_httpd="$(docker buildx imagetools inspect httpd:alpine | awk '$1 == "Digest:" {print $2; exit}')" + current_scriptomatic="$(git ls-remote https://github.com/infocyph/Scriptomatic.git refs/heads/main | awk 'NF {print $1; exit}')" + current_toolset="$(gh api repos/infocyph/Toolset/releases/latest --jq '.tag_name // empty')" + file="$(mktemp)" + trap 'rm -f "$file"' EXIT + curl --fail --silent --show-error --location \ + --retry 5 --retry-all-errors --retry-delay 2 \ + --connect-timeout 15 --max-time 120 \ + https://github.com/infocyph/Toolset/releases/latest/download/install.sh \ + -o "$file" + current_installer="$(sha256sum "$file" | awk '{print $1}')" + + test "$current_httpd" = "$HTTPD_ALPINE_DIGEST" + test "$current_scriptomatic" = "$SCRIPTOMATIC_MAIN_SHA" + test "$current_toolset" = "$TOOLSET_RELEASE" + test "$current_installer" = "$TOOLSET_INSTALLER_SHA256" - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Log in to GitHub Container Registry (GHCR) - uses: docker/login-action@v3 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - - name: Extract metadata (tags, labels) for Docker + - name: Enforce immutable release tags + if: env.PUBLISH_RELEASE_TAG == 'true' + env: + VERSION_TAG: ${{ env.RELEASE_TAG }} + run: | + set -euo pipefail + + assert_absent() { + local image="$1" output + if output="$(docker buildx imagetools inspect "$image" 2>&1)"; then + echo "Refusing to overwrite immutable release tag: $image" >&2 + return 1 + fi + case "$output" in + *'not found'*|*'manifest unknown'*|*'NAME_UNKNOWN'*) return 0 ;; + *) echo "$output" >&2; return 1 ;; + esac + } + + assert_absent "docker.io/infocyph/apache:${VERSION_TAG}" + assert_absent "ghcr.io/infocyph/apache:${VERSION_TAG}" + + - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | - docker.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + docker.io/infocyph/apache + ghcr.io/infocyph/apache + flavor: | + latest=false tags: | - ${{ env.RELEASE_TAG }} - latest + type=raw,value=${{ env.RELEASE_TAG }},enable=${{ env.PUBLISH_RELEASE_TAG == 'true' }} + type=raw,value=latest + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ env.APACHE_SOURCE_SHA }} + org.opencontainers.image.version=${{ env.RELEASE_TAG }} + org.opencontainers.image.base.name=docker.io/library/httpd:alpine + org.opencontainers.image.base.digest=${{ env.HTTPD_ALPINE_DIGEST }} - - name: Build and push Docker images + - name: Build and push multi-architecture image id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . + platforms: linux/amd64,linux/arm64 + pull: false push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: | + type=gha,scope=apache-publish-amd64 + type=gha,scope=apache-publish-arm64 + cache-to: type=gha,scope=apache-publish-multiarch,mode=max + provenance: mode=max + sbom: true - - name: Generate artifact attestation for Docker Hub - uses: actions/attest-build-provenance@v2 + - name: Generate Docker Hub provenance attestation + uses: actions/attest@v4 with: - subject-name: docker.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + subject-name: docker.io/infocyph/apache subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true - github-token: ${{ github.token }} - - name: Generate artifact attestation for GHCR - uses: actions/attest-build-provenance@v2 + - name: Generate GHCR provenance attestation + uses: actions/attest@v4 with: - subject-name: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + subject-name: ghcr.io/infocyph/apache subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true - github-token: ${{ github.token }} \ No newline at end of file + + - name: Verify published manifests and runtime + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -euo pipefail + + resolve_digest() { + local ref="$1" output digest + for _ in $(seq 1 12); do + output="$(docker buildx imagetools inspect "$ref" 2>&1 || true)" + digest="$(printf '%s\n' "$output" | awk '/^Digest:/ {print $2; exit}')" + if [[ -n "$digest" ]]; then + printf '%s\n' "$digest" + return 0 + fi + sleep 5 + done + echo "Unable to resolve digest for $ref" >&2 + return 1 + } + + docker_ref="docker.io/infocyph/apache:${VERIFY_TAG}" + ghcr_ref="ghcr.io/infocyph/apache:${VERIFY_TAG}" + test "$(resolve_digest "$docker_ref")" = "$DIGEST" + test "$(resolve_digest "$ghcr_ref")" = "$DIGEST" + + manifest="$(docker buildx imagetools inspect "$ghcr_ref")" + grep -Fq 'linux/amd64' <<<"$manifest" + grep -Fq 'linux/arm64' <<<"$manifest" + + published="ghcr.io/infocyph/apache@${DIGEST}" + docker pull --platform linux/amd64 "$published" >/dev/null + bash tests/release-gate.sh "$published" + + published_apache="$(docker run --rm --entrypoint httpd "$published" -v | sed -n 's#^Server version: Apache/\([^ ]*\).*#\1#p')" + published_alpine="$(docker run --rm --entrypoint cat "$published" /etc/alpine-release)" + published_chromacat="$(docker run --rm --entrypoint chromacat "$published" --version | head -n 1)" + published_banner="$(docker run --rm --entrypoint sha256sum "$published" /usr/local/bin/show-banner | awk '{print $1}')" + + test "$published_apache" = "$CANDIDATE_APACHE_VERSION" + test "$published_alpine" = "$CANDIDATE_ALPINE_VERSION" + test "$published_chromacat" = "$CANDIDATE_CHROMACAT_VERSION" + test "$published_banner" = "$CANDIDATE_BANNER_SHA256" + + { + echo '## Published Apache image' + echo + echo "- Source release: \`$RELEASE_TAG\`" + echo "- Digest: \`$DIGEST\`" + echo "- Apache: \`$published_apache\`" + echo "- Alpine: \`$published_alpine\`" + echo "- Toolset: \`$TOOLSET_RELEASE\`" + echo "- Scriptomatic main: \`$SCRIPTOMATIC_MAIN_SHA\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile index 4c02906..0a0dabb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM httpd:alpine LABEL org.opencontainers.image.source="https://github.com/infocyph/docker-apache" -LABEL org.opencontainers.image.description="Apache" +LABEL org.opencontainers.image.description="Hardened LocalDevStack Apache backend with PHP-FPM, TLS and HTTP/2 support" LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.authors="infocyph,abmmhasan" @@ -14,9 +14,9 @@ ENV APACHE_LOG_DIR=/var/log/apache2 \ TZ=${TZ} RUN set -eux; \ + apk upgrade --no-cache; \ apk add --no-cache \ apache2-utils \ - apache-mod-fcgid \ tzdata \ bash \ figlet \ @@ -25,37 +25,77 @@ RUN set -eux; \ gawk \ curl \ ca-certificates; \ + update-ca-certificates; \ mkdir -p \ /etc/profile.d \ - /usr/local/apache2/conf/vhosts; \ - rm -rf /var/cache/apk/* /tmp/* /var/tmp/* - -RUN set -eux; \ - curl -fsSL "https://raw.githubusercontent.com/infocyph/Scriptomatic/master/bash/banner.sh" -o /usr/local/bin/show-banner; \ - curl -fsSL "https://raw.githubusercontent.com/infocyph/Toolset/main/ChromaCat/chromacat" -o /usr/local/bin/chromacat; \ - chmod +x /usr/local/bin/show-banner /usr/local/bin/chromacat + /usr/local/apache2/conf/vhosts \ + "$APACHE_LOG_DIR"; \ + chmod 0755 "$APACHE_LOG_DIR" COPY scripts/update_httpd.sh /usr/local/bin/update_httpd.sh COPY scripts/entrypoint.sh /usr/local/bin/entrypoint COPY scripts/healthcheck.sh /usr/local/bin/healthcheck RUN set -eux; \ - chmod +x /usr/local/bin/update_httpd.sh /usr/local/bin/entrypoint /usr/local/bin/healthcheck; \ + curl -fsSL --retry 3 --retry-all-errors --retry-delay 1 \ + --connect-timeout 10 --max-time 120 \ + "https://raw.githubusercontent.com/infocyph/Scriptomatic/main/bash/banner.sh" \ + -o /usr/local/bin/show-banner; \ + test -s /usr/local/bin/show-banner; \ + bash -n /usr/local/bin/show-banner; \ + curl -fsSL --retry 3 --retry-all-errors --retry-delay 1 \ + --connect-timeout 10 --max-time 120 \ + "https://github.com/infocyph/Toolset/releases/latest/download/install.sh" \ + -o /tmp/toolset-install.sh; \ + test -s /tmp/toolset-install.sh; \ + bash -n /tmp/toolset-install.sh; \ + bash /tmp/toolset-install.sh --prefix /usr/local/bin chromacat; \ + chromacat --version; \ + rm -f /tmp/toolset-install.sh; \ + chmod +x \ + /usr/local/bin/update_httpd.sh \ + /usr/local/bin/entrypoint \ + /usr/local/bin/healthcheck \ + /usr/local/bin/show-banner \ + /usr/local/bin/chromacat; \ /usr/local/bin/update_httpd.sh; \ + httpd -t; \ + httpd -M > /tmp/httpd.modules; \ + for module in \ + proxy_module \ + proxy_fcgi_module \ + setenvif_module \ + rewrite_module \ + ssl_module \ + socache_shmcb_module \ + headers_module \ + deflate_module \ + http2_module; do \ + grep -Fq " ${module} (shared)" /tmp/httpd.modules || { echo "Required Apache module not loaded: ${module}" >&2; exit 1; }; \ + done; \ + rm -f /tmp/httpd.modules; \ + apk info -e apache2-utils >/dev/null; \ + ! apk info -e apache2 >/dev/null 2>&1; \ + ! apk info -e apache-mod-fcgid >/dev/null 2>&1; \ + command -v ab >/dev/null; \ + command -v htpasswd >/dev/null; \ { \ echo '#!/bin/sh'; \ - echo 'if [ -n "$PS1" ] && [ -z "${BANNER_SHOWN-}" ]; then'; \ - echo ' export BANNER_SHOWN=1'; \ - echo " APACHE_VERSION=\$(httpd -v | sed -n 's|^Server version: Apache/\\([0-9.]*\\).*|\\1|p')"; \ - echo ' show-banner "APACHE $APACHE_VERSION"'; \ - echo 'fi'; \ + echo 'case "$-" in *i*) ;; *) return 0 ;; esac'; \ + echo '[ -z "${BANNER_SHOWN-}" ] || return 0'; \ + echo 'command -v show-banner >/dev/null 2>&1 || return 0'; \ + echo 'BANNER_SHOWN=1'; \ + echo 'export BANNER_SHOWN'; \ + echo 'APACHE_VERSION="$(httpd -v | sed -n '\''s|^Server version: Apache/\([0-9.]*\).*|\1|p'\'')"'; \ + echo 'show-banner "Apache ${APACHE_VERSION:-unknown}"'; \ } > /etc/profile.d/banner-hook.sh; \ chmod +x /etc/profile.d/banner-hook.sh; \ - echo 'source /etc/profile.d/banner-hook.sh 2>/dev/null || true' >> /root/.bashrc + printf '\n[ -r /etc/profile.d/banner-hook.sh ] && . /etc/profile.d/banner-hook.sh\n' >> /root/.bashrc WORKDIR /app EXPOSE 80 443 + HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["/usr/local/bin/healthcheck"] ENTRYPOINT ["/usr/local/bin/entrypoint"] -CMD ["httpd-foreground"] \ No newline at end of file +CMD ["httpd-foreground"] diff --git a/README.md b/README.md index 8bd701a..8fa78fb 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,134 @@ # docker-apache +[![Check](https://github.com/infocyph/docker-apache/actions/workflows/check.yml/badge.svg)](https://github.com/infocyph/docker-apache/actions/workflows/check.yml) [![Docker Publish](https://github.com/infocyph/docker-apache/actions/workflows/docker.publish.yml/badge.svg)](https://github.com/infocyph/docker-apache/actions/workflows/docker.publish.yml) ![Docker Pulls](https://img.shields.io/docker/pulls/infocyph/apache) ![Docker Image Size](https://img.shields.io/docker/image-size/infocyph/apache) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -[![Base: Alpine](https://img.shields.io/badge/Base-Alpine-brightgreen.svg)](https://alpinelinux.org) -A custom **Apache HTTP Server** Docker image built on top of the lightweight [`httpd:alpine`](https://hub.docker.com/_/httpd). -It ships with a small config updater that enables commonly needed modules (proxy/FCGI, SSL, rewrite, headers, etc.) and applies a few sane defaults. +Hardened Apache HTTP Server image for LocalDevStack, built on the rolling official `httpd:alpine` base. ---- +Apache is an **optional backend** for projects that require Apache semantics. Nginx remains the LocalDevStack edge/router; Apache serves generated vhosts and forwards PHP requests to PHP-FPM with `mod_proxy_fcgi`. -## Features +## Runtime contract -- **Small footprint**: based on `httpd:alpine` -- **Auto configuration**: enables essential modules and settings on build - - `mod_proxy`, `mod_proxy_fcgi` - - `mod_rewrite` - - `mod_ssl` + `socache_shmcb` - - `mod_headers`, `mod_deflate` -- **Timezone support** via `tzdata` + `TZ` env -- **Nice interactive banner** when you open a shell inside the container (optional convenience) +- Official rolling base: `httpd:alpine` +- Published images: + - `docker.io/infocyph/apache` + - `ghcr.io/infocyph/apache` +- Project root: `/app` +- Generated vhosts: `/usr/local/apache2/conf/vhosts/*.conf` +- Default log directory: `/var/log/apache2` +- Listeners: `80`, `443` +- Main process: `httpd-foreground` ---- +The image enables the Apache modules LocalDevStack needs for proxy/FCGI, rewrite, headers, compression, TLS and HTTP/2. PHP-FPM uses `mod_proxy_fcgi`; `mod_fcgid` is intentionally not installed. -## Quick Start +`apache2-utils` is intentionally retained for utilities such as `ab` and `htpasswd`, including future LocalDevStack/debugging use. The Alpine `apache2` server package itself is not installed, so the image contains only the official `/usr/local/apache2` runtime. -### Build +## Environment variables -```bash -docker build -t infocyph/docker-apache:latest . -```` +| Variable | Default | Purpose | +| --- | --- | --- | +| `TZ` | `Asia/Dhaka` | Container timezone. Invalid values are non-fatal and produce a warning. | +| `SERVER_NAME` | `localhost` | Runtime Apache `ServerName`; evaluated when Apache starts. | +| `APACHE_LOG_DIR` | `/var/log/apache2` | Runtime log directory used by generated vhosts. | -### Run +Example: ```bash -docker run -d \ - --name apache \ - -p 80:80 -p 443:443 \ +docker run --rm \ -e TZ=Asia/Dhaka \ - -e SERVER_NAME=localhost \ - infocyph/docker-apache:latest + -e SERVER_NAME=example.localhost \ + -e APACHE_LOG_DIR=/var/log/apache2 \ + infocyph/apache:latest \ + httpd -t +``` + +## LocalDevStack mounts + +A normal LocalDevStack Apache service mounts project data and generated configuration rather than generating them inside this image: + +```text +/app project source +/usr/local/apache2/conf/vhosts generated Apache vhosts (read-only) +/etc/mkcert LocalDevStack server certificates (read-only) +/etc/share/rootCA LocalDevStack CA material (read-only) +/run/php-fpm shared PHP-FPM sockets when socket mode is used +/var/log/apache2 Apache logs +``` + +Both PHP-FPM generator forms are supported: + +```apache +SetHandler "proxy:fcgi://php-service:9000" +``` + +and: + +```apache +SetHandler "proxy:unix:/run/php-fpm/example.sock|fcgi://localhost/" ``` ---- +## Backend TLS / mTLS -## Environment Variables +LocalDevStack may proxy HTTPS from Nginx to Apache using backend mTLS. The image supports the generated contract where Apache presents the LocalDevStack server certificate and requires a client certificate signed by the mounted LocalDevStack CA. -| Variable | Default | Description | -| ---------------- | -----------------: | -------------------------------------------------- | -| `TZ` | *(empty)* | Container timezone (example: `Asia/Dhaka`) | -| `SERVER_NAME` | `localhost` | Apache `ServerName` used by the config updater | -| `APACHE_LOG_DIR` | `/var/log/apache2` | Log directory path (mount if you want persistence) | +The repository release gate creates ephemeral certificates and proves a real Nginx -> Apache mTLS request. No private certificate material is stored in this repository or baked into the image. -> Note: `SERVER_NAME` is applied by the build-time config updater in the current image design. +## Healthcheck ---- +The Docker healthcheck verifies: -## Logs (optional) +1. Apache configuration is valid; +2. the generated-vhost directory exists; +3. TLS files referenced by mounted vhosts are readable; +4. Apache is actually reachable on `127.0.0.1:80` within bounded timeouts. -To persist logs on the host: +Health is about Apache/runtime reachability, not application response semantics. A redirect, authentication response or `404` can still prove the listener is healthy. + +Inspect health diagnostics with: + +```bash +docker inspect --format '{{json .State.Health}}' APACHE +docker exec APACHE /usr/local/bin/healthcheck +``` + +## Standalone smoke + +The image is primarily intended for LocalDevStack, but a minimal standalone smoke is useful for debugging: ```bash -docker run -d \ - --name apache \ - -p 80:80 -p 443:443 \ - -v "$(pwd)/logs:/var/log/apache2" \ - infocyph/docker-apache:latest +docker run -d --name apache-smoke -p 8080:80 infocyph/apache:latest +curl -I http://127.0.0.1:8080/ +docker exec apache-smoke httpd -t +docker exec apache-smoke httpd -M +docker rm -f apache-smoke ``` ---- +## Included utilities + +Interactive shells include the shared Infocyph Scriptomatic banner and ChromaCat tooling. `ab` and `htpasswd` are provided by `apache2-utils`. -## Validate Inside the Container +Useful checks: ```bash -docker exec -it apache sh -lc 'httpd -v && httpd -M | head' +httpd -v +httpd -t +httpd -M +ab -V +htpasswd -h +chromacat --version ``` ---- +## Release model + +Published version tags are immutable. A GitHub release publishes its exact version tag plus `latest` to Docker Hub and GHCR. + +Scheduled/manual refreshes rebuild **only `latest`** from the most recent stable published source so the rolling `httpd:alpine`, Scriptomatic `main`, and Toolset `latest` inputs can receive compatible upstream updates without rewriting a historical version tag. + +Before publication, CI/release gates verify amd64 and arm64 builds, configuration idempotency, PHP-FPM TCP/socket syntax, listener health, read-only mounts, Nginx -> Apache mTLS, package inventory, vulnerability status and clean process shutdown. ## License -MIT — see [MIT License](https://opensource.org/licenses/MIT). +MIT — see [LICENSE](LICENSE). diff --git a/docs/plans/docker-apache-hardening-plan.md b/docs/plans/docker-apache-hardening-plan.md new file mode 100644 index 0000000..353ea20 --- /dev/null +++ b/docs/plans/docker-apache-hardening-plan.md @@ -0,0 +1,432 @@ +# docker-apache — Hardening & Release Plan + +## Status + +Planning branch: `plan/docker-apache-hardening` + +Baseline: + +- Repository: `infocyph/docker-apache` +- Default branch: `main` +- Current published release: `0.3.1` +- Current upstream base: `httpd:alpine` +- Review snapshot (2026-09-17): the rolling official tag currently resolves through the Apache 2.4.68 / Alpine 3.24 line; this is informational only and must not replace the rolling `httpd:alpine` contract. +- Runtime role: optional LocalDevStack Apache HTTP backend, mainly for Apache-compatible project routing and PHP-FPM/FastCGI integration +- Completed lower layers: shared Scriptomatic/Toolset foundations, `infocyph/runner:0.5`, `infocyph/nginx:0.4.1` +- Current LocalDevStack Apache HTTPS path: Nginx proxies to Apache over backend TLS with client-certificate verification; the release gate therefore needs a real Nginx -> Apache mTLS test, not only standalone Apache SSL syntax validation. + +This plan supersedes the older LocalDevStack ecosystem-only Apache draft. It is based on the current repository and the contracts actually shipped by the lower layers. + +### Hardline review additions + +The repository review adds these requirements to the original draft: + +1. Remove `apache-mod-fcgid`. LocalDevStack uses the official image's `mod_proxy_fcgi`; Alpine's `apache-mod-fcgid` is a separate module/package path and can pull Alpine's `apache2` server stack into the official `/usr/local/apache2` image. The final image must contain one Apache runtime, not two parallel distributions. +2. Remove `apache2-utils` unless an explicit runtime consumer is proven before implementation. No current `docker-apache`, LocalDevStack, or `docker-tools` contract requires its `ab`/`htpasswd`/related utilities. +3. Fix `SERVER_NAME` as a real runtime environment contract. The current updater expands it during the image build and therefore bakes `localhost` into `httpd.conf`. +4. Make `APACHE_LOG_DIR` a real runtime contract: create the default directory, support an override, and fail with an actionable startup/config error if the selected path cannot be used by the generated vhost contract. +5. Stop deleting or broadly rewriting unrelated upstream `conf/extra` includes. The image should own only the minimum Apache configuration it intentionally changes. +6. Add a small global defense-in-depth baseline: `ServerTokens Prod`, `ServerSignature Off`, `TraceEnable Off`, and `ProxyRequests Off`. Do not add application policy, HSTS, WAF rules, or project-specific access rules globally. +7. Make health failures diagnostic and always verify the port-80 listener even when no generated vhosts exist. An empty vhost directory is valid; an unreachable Apache listener is not healthy. +8. Add explicit read-only vhost/certificate mount tests matching LocalDevStack. +9. Add a real Nginx -> Apache backend-mTLS compatibility fixture using the current certificate and generated-vhost conventions. +10. Add workflow/config linting, native BuildKit validation, candidate vulnerability scanning, package-inventory checks, and build-cache use without weakening fresh-base behavior. +11. Fix README drift, including the current wrong run/build image name (`infocyph/docker-apache` vs published `infocyph/apache`) and the stale claim that `SERVER_NAME` is build-time only. + +--- + +# 1. Goal + +Harden `docker-apache` as a small, predictable, independently publishable Apache backend for LocalDevStack while preserving its optional role behind Nginx. + +Apache should remain responsible for: + +1. providing Apache semantics for projects that need them; +2. serving generated Apache vhosts; +3. proxying PHP requests to PHP-FPM through `mod_proxy_fcgi`; +4. supporting rewrite/header/SSL/HTTP2 behavior required by generated LocalDevStack vhosts; +5. exposing reliable configuration and runtime health signals. + +Apache must not become a second LocalDevStack edge router or control plane. Nginx remains the primary LocalDevStack HTTP/TLS edge. + +--- + +# 2. Architecture invariants + +Preserve these contracts unless tests prove a contract is broken: + +- Keep `httpd:alpine` as the rolling upstream base. +- Keep Apache optional; LocalDevStack must still work for projects routed directly through Nginx/PHP-FPM or Node. +- Keep generated vhosts under `/usr/local/apache2/conf/vhosts`. +- Keep project source under `/app`. +- Keep Docker DNS/service names as the upstream contract; do not add static-IP dependencies inside this image. Current LocalDevStack compose may still assign fixed addresses; migrating that higher-layer compose contract is outside this release. +- Keep PHP-FPM connectivity compatible with the current `docker-tools` vhost generator, including `SetHandler "proxy:fcgi://:9000"` and Unix-socket variants where generated. +- Keep TLS material mounted from LocalDevStack rather than generated inside Apache. +- Keep backend HTTPS compatible with the current Nginx -> Apache mTLS contract. +- Keep `APACHE_LOG_DIR` compatible with generated vhosts that use `${APACHE_LOG_DIR}`. +- Keep `httpd-foreground` as the main runtime process and preserve normal Docker signal semantics. +- Keep the vhost and certificate mounts usable as read-only inputs. +- Do not add LocalDevStack orchestration, database, AI, model, or certificate-issuance responsibilities to this image. + +--- + +# 3. Shared foundation contracts + +## 3.1 Scriptomatic + +Replace the stale `Scriptomatic/master` fetch with the completed canonical distribution contract: + +```text +https://raw.githubusercontent.com/infocyph/Scriptomatic/main/bash/banner.sh +``` + +Use bounded retries/timeouts, verify the file is non-empty, and syntax-check it with Bash before installation. + +Do not create a Scriptomatic release/tag dependency; `main` is intentionally the canonical Scriptomatic channel. + +## 3.2 Toolset + +Replace the raw `Toolset/main/ChromaCat/chromacat` download with the stable checksum-verifying Toolset installer: + +```text +https://github.com/infocyph/Toolset/releases/latest/download/install.sh +``` + +Install only `chromacat` to `/usr/local/bin`, validate the installer before running it, and verify `chromacat --version` during the build. + +This should follow the same downstream convention already shipped by Runner 0.5 and Nginx 0.4.1. + +Do not use `chromacat --self-update` as an image maintenance mechanism. Container contents remain immutable; helper updates arrive through a new image build. + +--- + +# 4. File-by-file implementation plan + +## 4.1 `Dockerfile` + +Current concerns: + +- stale Scriptomatic `master` URL; +- raw mutable Toolset source download; +- `apache-mod-fcgid` is not the FastCGI path used by LocalDevStack and can install Alpine's separate Apache stack alongside the official image; +- `apache2-utils` has no proven current runtime consumer; +- rolling `httpd:alpine` base has no permanent compatibility gate; +- build validates configuration only indirectly through `update_httpd.sh`; +- `/var/log/apache2` is an advertised/generated-vhost path but is not explicitly created by the image; +- banner hook can be made consistent with the hardened Runner/Nginx convention. + +Plan: + +1. Keep `FROM httpd:alpine` and keep it rolling; do not pin Apache or Alpine in the Dockerfile. +2. Remove `apache-mod-fcgid`. Use the `mod_proxy_fcgi.so` already shipped with the official `/usr/local/apache2` runtime. +3. Remove `apache2-utils` unless implementation uncovers a documented runtime requirement. If retained, document the exact command/consumer and test it. +4. Add a CI/package-inventory assertion that the final image does not accidentally install Alpine's `apache2` server package or `apache-mod-fcgid`. +5. Keep only runtime packages that have a proven purpose: + - `bash`, `figlet`, `ncurses`, and `gawk` for the Scriptomatic/ChromaCat interactive tooling; + - `tzdata` for runtime timezone support; + - `musl-locales` while `LANG`/`LC_ALL` remain part of the shell UX contract; + - `curl` for helper acquisition and runtime health checks; + - `ca-certificates` for HTTPS verification. +6. Remove redundant cleanup that `apk add --no-cache` already makes unnecessary unless a measurable layer reduction remains. +7. Create `/usr/local/apache2/conf/vhosts` and the default `${APACHE_LOG_DIR}` (`/var/log/apache2`) explicitly. +8. Migrate Scriptomatic banner consumption to `main` using bounded download behavior. +9. Migrate `chromacat` to Toolset's latest stable installer. +10. Verify downloaded helper syntax/version before finalizing the layer. +11. Copy image-owned scripts only from this repository. +12. Run `update_httpd.sh` during build, then run both `httpd -t` and a required-module inventory check (`httpd -M`) as build-time gates. +13. Preserve `/app` as the working directory. +14. Preserve ports `80` and `443`; Apache being behind Nginx does not require inventing additional ports. +15. Preserve one Docker `HEALTHCHECK`, implemented by the repository health script. +16. Use OCI version/revision/created/source/base metadata from the publication workflow rather than hard-coded release metadata. +17. Keep the final image root-capable because Apache startup, privileged ports, runtime timezone setup, and current official-image behavior rely on it. A non-root conversion is a separate compatibility project, not a hidden change in this release. +18. Record/report final image size and package inventory in CI so removals are visible and accidental image growth is reviewable; avoid an arbitrary absolute size threshold tied to a rolling base. + +## 4.2 `scripts/update_httpd.sh` + +This script is the most important Apache-specific hardening target. + +Plan: + +1. Preserve idempotent enabling of required modules/directives. +2. Validate every requested module file against the actual official `httpd:alpine` module set in CI before modifying configuration. +3. Keep required modules for current LocalDevStack behavior: + - `proxy_module`; + - `proxy_fcgi_module`; + - `setenvif_module`; + - `rewrite_module`; + - `ssl_module`; + - `socache_shmcb_module`; + - `headers_module`; + - `deflate_module`; + - `http2_module`. +4. Preserve `IncludeOptional conf/vhosts/*.conf` so an empty vhost directory is valid. +5. Make `SERVER_NAME` runtime-configurable. Do not interpolate `${SERVER_NAME}` in the build shell; write a runtime Apache environment reference (with the image default remaining `localhost`) and test `docker run -e SERVER_NAME=...` explicitly. +6. Preserve `APACHE_LOG_DIR` environment expansion used by current generated vhosts and validate the default/override paths through tests. +7. Keep `Listen 80` / `Listen 443` only if the base configuration still requires explicit normalization; prove this against fresh upstream images. +8. Add only the small global hardening baseline owned by this image: + - `ServerTokens Prod`; + - `ServerSignature Off`; + - `TraceEnable Off`; + - `ProxyRequests Off`. +9. Keep the current SSL session-cache behavior only if the current LocalDevStack backend-TLS fixtures still justify it; otherwise prefer upstream defaults over unexplained tuning. +10. Make repeated execution produce the same `httpd.conf` byte-for-byte after the first successful run. +11. Do not delete broad `conf/extra/*.conf` include patterns or otherwise remove unrelated upstream configuration. If one upstream include is demonstrably incompatible, target that exact line and cover it with a regression fixture. +12. Avoid broad regex replacement that could modify unrelated upstream configuration. +13. Run `httpd -t` after mutation and fail the image build on invalid output. +14. Add fixtures proving PHP-FPM proxy directives, rewrite rules, SSL/mTLS vhosts, HTTP/2 directives, and ordinary static vhosts remain valid. +15. Keep this script in the final image for diagnostics/reproducibility; remove the current self-deletion behavior. +16. Emit concise actionable failure messages; success output should remain minimal. + +## 4.3 `scripts/entrypoint.sh` + +Plan: + +- preserve timezone setup; +- keep invalid timezone best-effort rather than fatal; +- normalize an empty/unset `SERVER_NAME` to `localhost` for the runtime Apache substitution contract; +- normalize an empty/unset `APACHE_LOG_DIR` to `/var/log/apache2`; +- ensure the selected log directory exists and report a clear error if it cannot be created/used; +- keep diagnostics on stderr; +- preserve arbitrary command overrides; +- preserve final `exec "$@"` exactly so signals reach `httpd-foreground`; +- do not add background workers to the Apache entrypoint; +- add entrypoint smoke coverage for valid/invalid/unset `TZ`, runtime `SERVER_NAME`, log-dir override, and command override. + +## 4.4 `scripts/healthcheck.sh` + +Current behavior validates `httpd -t`, conditionally checks mounted TLS files, and curls localhost only when at least one vhost exists. + +Plan: + +1. Keep configuration validation as the first gate. +2. Keep an empty vhost directory valid. +3. Always verify the port-80 Apache listener, even with zero generated vhosts. Do not use `curl --fail`; any valid HTTP response status can prove the listener is alive. +4. Use bounded connect and total timeouts so a broken listener cannot stall Docker health checks. +5. For configured SSL vhosts, verify required server certificate/key and CA inputs referenced by the current LocalDevStack template are readable. +6. Do not require the Apache container to own/use the Nginx client's private key merely to satisfy health checking. Real backend mTLS is validated from the proxy/client side in CI. +7. Keep health semantics about Apache/runtime reachability, not application correctness: redirects, authentication responses, or expected `404`s are healthy if the listener/configuration is functioning. +8. Print one concise reason to stderr on failure (`config invalid`, `listener unreachable`, `certificate missing`, etc.) and stay quiet on success so `docker inspect` health output is useful. +9. Add dedicated fixtures for empty-vhost, HTTP-only, SSL-enabled, missing-TLS-input, and broken-listener cases. + +## 4.5 Apache global hardening boundary + +The image may enforce only server-wide protections that are safe for all generated LocalDevStack vhosts: + +```text +ServerTokens Prod +ServerSignature Off +TraceEnable Off +ProxyRequests Off +``` + +Do not globally inject: + +- HSTS; +- CSP or application security headers; +- directory/index policy; +- `AllowOverride` policy; +- request-body limits; +- project-specific file deny rules; +- WAF/ModSecurity; +- edge TLS cipher policy beyond what is required to support the generated Apache backend vhosts. + +Those belong to generated vhosts or the Nginx edge and must remain independently evolvable. + +## 4.6 `README.md` + +Reconcile documentation with the actual role and published image contract: + +- Apache is an optional LocalDevStack backend, not the primary edge; +- Nginx 0.4.1 is the primary HTTP/TLS router in the current lower-layer architecture; +- use the published image name `infocyph/apache`, not `infocyph/docker-apache`, in build/run/pull examples; +- explain `/app`, vhost, TLS and log mounts; +- document `SERVER_NAME`, `TZ`, and `APACHE_LOG_DIR` as runtime environment contracts and show their actual defaults; +- remove the statement that `SERVER_NAME` is build-time only after the runtime fix lands; +- document health behavior and how to inspect the last health failure; +- document Docker Hub/GHCR tags and immutable release-tag semantics; +- explain that `latest` can be refreshed from a published release against a newer rolling `httpd:alpine` base while the immutable release tag is never overwritten; +- remove examples that imply static container IPs are required; +- add a minimal standalone smoke example only for debugging the image; +- add concise troubleshooting commands for `httpd -t`, `httpd -M`, `/usr/local/bin/healthcheck`, and version inspection. + +## 4.7 `.github/workflows/check.yml` — new + +Add permanent validation on PRs/pushes and a scheduled rolling-upstream compatibility run: + +- `sh -n`/`bash -n` according to each script's declared shell; +- ShellCheck; +- `actionlint` for workflow syntax/expressions; +- native BuildKit/Dockerfile validation (`docker buildx build --check` with the current supported BuildKit interface); +- fresh `httpd:alpine` image build with pull enabled; +- build-time `httpd -t` and required `httpd -M` assertions; +- final package inventory proving Alpine's `apache2`/`apache-mod-fcgid` server path is absent; +- final image size/package report; +- container startup and Docker-health verification; +- default empty-vhost smoke plus real listener check; +- synthetic static vhost smoke; +- PHP-FPM TCP and supported Unix-socket proxy syntax fixtures; +- runtime `SERVER_NAME` override test; +- runtime `APACHE_LOG_DIR` override test; +- global hardening-directive assertion; +- SSL-vhost fixture with ephemeral CI certificates; +- real Nginx -> Apache backend-mTLS fixture matching current `docker-tools` certificate names/verification behavior; +- read-only generated-vhost and certificate mounts; +- entrypoint timezone tests; +- arbitrary command override test; +- clean SIGTERM shutdown; +- architecture-appropriate release contract checks. + +No private certificate material is committed; generate CI fixtures at runtime. + +The scheduled compatibility run exists because `httpd:alpine` is intentionally rolling. It must use a fresh base pull so upstream changes are discovered before a release/refresh path surprises the repository. + +## 4.8 `.github/workflows/docker.publish.yml` + +Replace the legacy workflow with the hardened publication contract already proven by Runner 0.5 / Nginx 0.4.1: + +- release events use `github.event.release.tag_name` directly; do not resolve "latest release" during a release event; +- a release event publishes the immutable release tag + `latest`; +- scheduled/manual refresh resolves the latest published Apache release source and publishes only `latest`; +- scheduled/manual refresh must never write an existing version tag; +- replace the current ambiguous `0 0 * * */2` cadence with one explicit documented refresh cadence; prefer a simple daily upstream refresh over a misleading "every two days" cron expression; +- `workflow_dispatch` support for an explicit latest refresh without mutating version tags; +- concurrency guard preventing overlapping publish/refresh runs; +- job/step timeouts; +- current supported Action majors at implementation time; +- least-privilege job permissions, with registry/package/attestation permissions isolated to the publish path; +- QEMU + Buildx setup where required for tested multi-arch publication; +- fresh release-candidate build with pull enabled before registry login/publish; +- run the same Apache config/runtime/LocalDevStack compatibility gates against the candidate before credentials are used; +- candidate vulnerability scan before publication; block known fixable `CRITICAL` findings and report remaining findings without pretending an unfixed upstream issue can be repaired in this repository; +- amd64 + arm64 where the official base and runtime tests pass; +- Docker Hub + GHCR from one multi-arch build; +- GHA BuildKit cache may be used for unchanged layers, but base-image freshness must remain enabled and correctness must not depend on cache hits; +- BuildKit provenance; +- SBOM; +- GitHub attestations; +- OCI source/revision/version/created/base-name metadata; +- post-publish digest verification in both registries; +- on a release event, verify release-tag and `latest` resolve to the expected published digest; +- LocalDevStack compatibility gate using the current Apache compose/vhost/backend-mTLS contract. + +## 4.9 `.dockerignore`, `.gitignore`, `.gitattributes`, `LICENSE` + +- keep build context minimal; +- preserve LF for shell files; +- add `tests/` and generated fixture paths to `.dockerignore` once the new test suite exists, because the runtime Dockerfile copies only image-owned runtime files; +- add test/generated fixture ignores to `.gitignore` only when they are actually generated in-tree; +- no license change. + +--- + +# 5. New test surface + +Suggested files: + +```text +tests/static.sh +tests/image-smoke.sh +tests/httpd-config.sh +tests/healthcheck.sh +tests/runtime-env.sh +tests/package-inventory.sh +tests/hardening.sh +tests/readonly-mounts.sh +tests/mtls-proxy.sh +tests/release-gate.sh +tests/fixtures/vhosts/static.conf +tests/fixtures/vhosts/php-fpm.conf +tests/fixtures/vhosts/ssl.conf +``` + +The fixtures should be minimal and test Apache contracts, not duplicate the full LocalDevStack generator. + +Where a compatibility behavior is owned by `docker-tools`, prefer consuming or translating the current template shape in the gate rather than creating a divergent second template specification. + +--- + +# 6. LocalDevStack compatibility gates + +Before release, prove compatibility with: + +1. current Nginx routing to Apache by Docker DNS/service name; +2. representative Apache project vhost; +3. PHP-FPM upstream generated by current LocalDevStack/docker-tools templates; +4. mounted project source under `/app`; +5. `${APACHE_LOG_DIR}`-based generated log paths; +6. shared LocalDevStack certificate paths; +7. Nginx -> Apache HTTPS with `lds-client-internal` client authentication, trusted root CA, SNI, and Apache `SSLVerifyClient require` behavior; +8. HTTP/2 directives used by the generated HTTPS Apache vhost; +9. vhost and certificate mounts operating read-only; +10. mounted logs expected by Runner 0.5; +11. absence of static-IP assumptions inside the Apache image. + +Current LocalDevStack compose still assigns fixed addresses to HTTP services. That is a higher-layer compose concern: this image must work without those addresses, while changing/removing them belongs to a later LocalDevStack change. + +The LocalDevStack version update happens only after the new Apache image is published and these gates pass. + +--- + +# 7. Explicit non-goals + +Do not add: + +- LLM/AI functionality; +- Ollama client/runtime packages; +- Docker socket requirements; +- project lifecycle commands; +- certificate generation; +- database tooling; +- Nginx-style reserved host routing; +- automatic vhost generation inside Apache; +- ModSecurity/WAF policy; +- application-level security headers; +- a second Alpine-packaged Apache runtime; +- hidden migration of LocalDevStack's fixed compose IPs; +- read-only-rootfs/non-root conversion without a dedicated compatibility project. + +Those belong to higher layers or separate projects. + +--- + +# 8. Acceptance criteria + +The Apache hardening release is ready when: + +1. permanent CI is green, including the scheduled rolling-upstream gate; +2. a fresh rolling `httpd:alpine` image builds successfully; +3. all repository scripts pass syntax/ShellCheck gates and workflows pass `actionlint`; +4. native BuildKit validation passes; +5. `httpd -t` passes after configuration mutation; +6. required official-image Apache modules are present/loaded and the Alpine `apache2`/`apache-mod-fcgid` server path is absent; +7. configuration mutation is idempotent and does not delete unrelated upstream config; +8. runtime `SERVER_NAME` override is proven rather than baked at build time; +9. default/overridden `APACHE_LOG_DIR` works with generated vhost logging; +10. empty-vhost, HTTP, SSL, and diagnostic failure health fixtures pass with bounded timeouts; +11. global defense-in-depth directives are active; +12. representative PHP-FPM and Apache vhosts validate; +13. Nginx -> Apache backend mTLS works with current LocalDevStack/docker-tools certificate conventions; +14. vhost/certificate read-only mounts work; +15. container shutdown is signal-clean; +16. Scriptomatic uses its current canonical channel and Toolset uses the stable installer; +17. candidate vulnerability scan has no known fixable `CRITICAL` blocker; +18. release tags cannot be overwritten by scheduled/manual rebuilds; +19. Docker Hub/GHCR multi-arch publish, SBOM/provenance/attestations, and digest verification pass; +20. LocalDevStack works through Nginx -> Apache without requiring fixed-IP coupling from the image; +21. README uses `infocyph/apache` and documents the actual runtime contract. + +--- + +# 9. Recommended implementation order + +1. Add tests/CI around the current behavior, including package inventory and rolling-base checks. +2. Remove the duplicate/unneeded Alpine Apache package path and harden shared helper installation. +3. Harden `update_httpd.sh`, add the safe global baseline, and make mutation provably idempotent/non-destructive. +4. Fix runtime `SERVER_NAME` and `APACHE_LOG_DIR` contracts. +5. Harden healthcheck/entrypoint diagnostics. +6. Add SSL, read-only-mount, and real Nginx -> Apache backend-mTLS gates. +7. Add the full LocalDevStack compatibility gate. +8. Modernize publication workflow and supply-chain verification. +9. Reconcile README and build context. +10. Run final fresh-upstream/release-candidate validation. +11. Publish the next Apache release. diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh old mode 100644 new mode 100755 index 31c87a0..6fcd2c3 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,20 +1,37 @@ #!/bin/sh set -eu +SERVER_NAME="${SERVER_NAME:-localhost}" +APACHE_LOG_DIR="${APACHE_LOG_DIR:-/var/log/apache2}" +export SERVER_NAME APACHE_LOG_DIR + configure_timezone() { - if [ -z "${TZ:-}" ]; then - return 0 - fi + if [ -z "${TZ:-}" ]; then + return 0 + fi + + if [ ! -f "/usr/share/zoneinfo/$TZ" ]; then + printf "[entrypoint] Warning: timezone '%s' not found under /usr/share/zoneinfo; keeping current timezone\n" "$TZ" >&2 + return 0 + fi + + ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime + printf '%s\n' "$TZ" > /etc/timezone +} - if [ ! -f "/usr/share/zoneinfo/$TZ" ]; then - echo "[entrypoint] Warning: timezone '$TZ' not found under /usr/share/zoneinfo; keeping current timezone" >&2 - return 0 - fi +configure_log_dir() { + if ! mkdir -p "$APACHE_LOG_DIR"; then + printf '[entrypoint] Error: unable to create Apache log directory: %s\n' "$APACHE_LOG_DIR" >&2 + exit 1 + fi - ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime - printf '%s\n' "$TZ" > /etc/timezone + if [ ! -d "$APACHE_LOG_DIR" ] || [ ! -w "$APACHE_LOG_DIR" ]; then + printf '[entrypoint] Error: Apache log directory is not writable: %s\n' "$APACHE_LOG_DIR" >&2 + exit 1 + fi } configure_timezone +configure_log_dir exec "$@" diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh old mode 100644 new mode 100755 index 191ef19..10192ee --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -3,19 +3,38 @@ set -eu HTTPD_CONF="/usr/local/apache2/conf/httpd.conf" VHOST_DIR="/usr/local/apache2/conf/vhosts" +SERVER_NAME="${SERVER_NAME:-localhost}" +APACHE_LOG_DIR="${APACHE_LOG_DIR:-/var/log/apache2}" +export SERVER_NAME APACHE_LOG_DIR -[ -r "$HTTPD_CONF" ] -[ -d "$VHOST_DIR" ] +fail() { + printf '[healthcheck] %s\n' "$*" >&2 + exit 1 +} -httpd -t >/dev/null 2>&1 +[ -r "$HTTPD_CONF" ] || fail "Apache configuration is not readable" +[ -d "$VHOST_DIR" ] || fail "Apache vhost directory is missing" -if find "$VHOST_DIR" -type f -name '*.conf' -print -quit 2>/dev/null | grep -q .; then - if grep -Rq 'SSLEngine[[:space:]]\+on' "$VHOST_DIR" 2>/dev/null; then - [ -f /etc/mkcert/lds-server.pem ] - [ -f /etc/mkcert/lds-server-key.pem ] - fi - - curl -sS -o /dev/null http://127.0.0.1/ +if ! httpd -t >/dev/null 2>&1; then + fail "Apache configuration is invalid" fi -exit 0 \ No newline at end of file +for tls_file in \ + /etc/mkcert/lds-server.pem \ + /etc/mkcert/lds-server-key.pem \ + /etc/share/rootCA/rootCA.pem; do + if grep -RqF "$tls_file" "$VHOST_DIR" 2>/dev/null && [ ! -r "$tls_file" ]; then + fail "Required TLS file is missing or unreadable: $tls_file" + fi +done + +if ! curl \ + --silent \ + --show-error \ + --output /dev/null \ + --noproxy '*' \ + --connect-timeout 2 \ + --max-time 4 \ + http://127.0.0.1/; then + fail "Apache HTTP listener is unreachable on 127.0.0.1:80" +fi diff --git a/scripts/update_httpd.sh b/scripts/update_httpd.sh old mode 100644 new mode 100755 index a25856e..daf7a86 --- a/scripts/update_httpd.sh +++ b/scripts/update_httpd.sh @@ -1,64 +1,87 @@ #!/bin/sh set -eu -HTTPD_CONF="/usr/local/apache2/conf/httpd.conf" +HTTPD_ROOT="/usr/local/apache2" +HTTPD_CONF="$HTTPD_ROOT/conf/httpd.conf" -sed -i '/^[[:space:]]*IncludeOptional[[:space:]]\+conf\/extra\/\*\.conf[[:space:]]*$/d' "$HTTPD_CONF" -sed -i '/^[[:space:]]*Include[[:space:]]\+conf\/extra\/\*\.conf[[:space:]]*$/d' "$HTTPD_CONF" -sed -i '/^[[:space:]]*IncludeOptional[[:space:]]\+conf\/extra\/httpd-dav\.conf[[:space:]]*$/d' "$HTTPD_CONF" -sed -i '/^[[:space:]]*Include[[:space:]]\+conf\/extra\/httpd-dav\.conf[[:space:]]*$/d' "$HTTPD_CONF" - -lines_to_update=" -LoadModule proxy_module modules/mod_proxy.so -LoadModule proxy_fcgi_module modules/mod_proxy_fcgi.so -LoadModule setenvif_module modules/mod_setenvif.so -LoadModule rewrite_module modules/mod_rewrite.so -LoadModule ssl_module modules/mod_ssl.so -LoadModule socache_shmcb_module modules/mod_socache_shmcb.so -LoadModule headers_module modules/mod_headers.so -LoadModule deflate_module modules/mod_deflate.so -LoadModule http2_module modules/mod_http2.so -SSLSessionCache shmcb:/usr/local/apache2/logs/ssl_scache(512000) -ServerName ${SERVER_NAME:-localhost} -SSLSessionCacheTimeout 86400 -Listen 80 -Listen 443 -IncludeOptional conf/vhosts/*.conf -" - -escape_sed_re() { - # Escape sed BRE meta chars + delimiter-sensitive chars. - # Covers: . [ ] * ^ $ \ ( ) { } + ? | and also / & - printf '%s' "$1" | sed 's/[.[\*^$\\(){}+?|]/\\&/g; s/[\/&]/\\&/g' +fail() { + printf '[update-httpd] %s\n' "$*" >&2 + exit 1 } +[ -r "$HTTPD_CONF" ] || fail "Apache configuration is not readable: $HTTPD_CONF" + ensure_line() { - line="$1" + wanted="$1" + tmp="$(mktemp)" - # If line exists commented or uncommented, normalize it to exactly the active form. - re="$(escape_sed_re "$line")" + awk -v wanted="$wanted" ' + BEGIN { found = 0 } + { + candidate = $0 + sub(/^[[:space:]]*/, "", candidate) + if (substr(candidate, 1, 1) == "#") { + sub(/^#[[:space:]]*/, "", candidate) + } + sub(/[[:space:]]*$/, "", candidate) - if grep -Fqx "$line" "$HTTPD_CONF"; then - return 0 - fi + if (candidate == wanted) { + if (!found) { + print wanted + found = 1 + } + next + } - # Replace a commented match (allow leading whitespace + # + whitespace) - if grep -Eq "^[[:space:]]*#[[:space:]]*${re}[[:space:]]*$" "$HTTPD_CONF"; then - # Use a regex that matches the whole line and rewrites to the exact desired line. - sed -i "s|^[[:space:]]*#[[:space:]]*${re}[[:space:]]*$|$line|g" "$HTTPD_CONF" - return 0 - fi + print + } + END { + if (!found) { + print wanted + } + } + ' "$HTTPD_CONF" > "$tmp" || { + rm -f "$tmp" + fail "Unable to update Apache configuration" + } - # Otherwise append - printf '%s\n' "$line" >> "$HTTPD_CONF" + cat "$tmp" > "$HTTPD_CONF" + rm -f "$tmp" } -# Process each desired configuration line. -printf '%s\n' "$lines_to_update" | while IFS= read -r config_line; do - [ -z "$config_line" ] && continue - ensure_line "$config_line" -done +while IFS='|' read -r module path; do + [ -n "$module" ] || continue + [ -f "$HTTPD_ROOT/$path" ] || fail "Required Apache module file is missing: $path" + ensure_line "LoadModule $module $path" +done <<'EOF' +proxy_module|modules/mod_proxy.so +proxy_fcgi_module|modules/mod_proxy_fcgi.so +setenvif_module|modules/mod_setenvif.so +rewrite_module|modules/mod_rewrite.so +ssl_module|modules/mod_ssl.so +socache_shmcb_module|modules/mod_socache_shmcb.so +headers_module|modules/mod_headers.so +deflate_module|modules/mod_deflate.so +http2_module|modules/mod_http2.so +EOF -echo "Apache configuration updated successfully." +while IFS= read -r directive; do + [ -n "$directive" ] || continue + ensure_line "$directive" +done <<'EOF' +ServerName ${SERVER_NAME} +ServerTokens Prod +ServerSignature Off +TraceEnable Off +ProxyRequests Off +SSLSessionCache shmcb:/usr/local/apache2/logs/ssl_scache(512000) +SSLSessionCacheTimeout 86400 +Listen 80 +Listen 443 +IncludeOptional conf/vhosts/*.conf +EOF -rm -f -- "$0" +if ! httpd -t >/dev/null 2>&1; then + httpd -t >&2 || true + fail "Apache configuration validation failed" +fi diff --git a/tests/fixtures/vhosts/php-fpm-socket.conf b/tests/fixtures/vhosts/php-fpm-socket.conf new file mode 100644 index 0000000..1622895 --- /dev/null +++ b/tests/fixtures/vhosts/php-fpm-socket.conf @@ -0,0 +1,11 @@ + + ServerName socket.apache.test + DocumentRoot /app + + AllowOverride All + Require all granted + + + SetHandler "proxy:unix:/run/php-fpm/socket.apache.test.sock|fcgi://localhost/" + + diff --git a/tests/fixtures/vhosts/php-fpm.conf b/tests/fixtures/vhosts/php-fpm.conf new file mode 100644 index 0000000..dbc5a0e --- /dev/null +++ b/tests/fixtures/vhosts/php-fpm.conf @@ -0,0 +1,13 @@ + + ServerName php.test + DocumentRoot /app + + + AllowOverride All + Require all granted + + + + SetHandler "proxy:fcgi://php:9000" + + diff --git a/tests/fixtures/vhosts/ssl.conf b/tests/fixtures/vhosts/ssl.conf new file mode 100644 index 0000000..2bd1d2c --- /dev/null +++ b/tests/fixtures/vhosts/ssl.conf @@ -0,0 +1,18 @@ + + ServerName apache.test + DocumentRoot /app + + SSLEngine on + SSLCertificateFile /etc/mkcert/lds-server.pem + SSLCertificateKeyFile /etc/mkcert/lds-server-key.pem + SSLCACertificateFile /etc/share/rootCA/rootCA.pem + SSLVerifyClient require + SSLVerifyDepth 2 + SSLProtocol -all +TLSv1.2 +TLSv1.3 + Protocols h2 http/1.1 + + + AllowOverride All + Require all granted + + diff --git a/tests/fixtures/vhosts/static.conf b/tests/fixtures/vhosts/static.conf new file mode 100644 index 0000000..c75b11c --- /dev/null +++ b/tests/fixtures/vhosts/static.conf @@ -0,0 +1,9 @@ + + ServerName static.test + DocumentRoot /app + + + AllowOverride All + Require all granted + + diff --git a/tests/healthcheck.sh b/tests/healthcheck.sh new file mode 100755 index 0000000..84b3df9 --- /dev/null +++ b/tests/healthcheck.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:-infocyph/apache:ci}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +static_vhost="$repo_root/tests/fixtures/vhosts/static.conf" +ssl_vhost="$repo_root/tests/fixtures/vhosts/ssl.conf" + +wait_healthy() { + local name="$1" status + for _ in $(seq 1 40); do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null || true)" + case "$status" in + healthy) return 0 ;; + unhealthy) + docker inspect --format '{{json .State.Health}}' "$name" >&2 || true + docker logs "$name" >&2 || true + return 1 + ;; + esac + sleep 1 + done + docker logs "$name" >&2 || true + return 1 +} + +name="apache-health-static-$$" +trap 'docker rm -f "$name" >/dev/null 2>&1 || true' EXIT INT TERM + +docker run -d --name "$name" \ + -v "$static_vhost:/usr/local/apache2/conf/vhosts/static.conf:ro" \ + "$image" >/dev/null +wait_healthy "$name" +docker rm -f "$name" >/dev/null + +docker run --rm --entrypoint sh "$image" -ec ' + if /usr/local/bin/healthcheck >/tmp/health.out 2>&1; then + echo "healthcheck unexpectedly passed without Apache running" >&2 + exit 1 + fi + grep -Fq "Apache HTTP listener is unreachable" /tmp/health.out +' + +docker run --rm --entrypoint sh \ + -v "$ssl_vhost:/usr/local/apache2/conf/vhosts/ssl.conf:ro" \ + "$image" -ec ' + if /usr/local/bin/healthcheck >/tmp/health.out 2>&1; then + echo "healthcheck unexpectedly passed with missing TLS inputs" >&2 + exit 1 + fi + grep -Eq "Apache configuration is invalid|Required TLS file is missing or unreadable" /tmp/health.out + ' + +echo 'Healthcheck contracts passed.' diff --git a/tests/httpd-config.sh b/tests/httpd-config.sh new file mode 100755 index 0000000..9c96cf5 --- /dev/null +++ b/tests/httpd-config.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:-infocyph/apache:ci}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tcp_vhost="$repo_root/tests/fixtures/vhosts/php-fpm.conf" +socket_vhost="$repo_root/tests/fixtures/vhosts/php-fpm-socket.conf" + +docker run --rm "$image" sh -ec ' + httpd -t + httpd -M > /tmp/modules + for module in proxy_module proxy_fcgi_module setenvif_module rewrite_module ssl_module socache_shmcb_module headers_module deflate_module http2_module; do + grep -Fq " ${module} (shared)" /tmp/modules + done + + apk info -e apache2-utils >/dev/null + ! apk info -e apache2 >/dev/null 2>&1 + ! apk info -e apache-mod-fcgid >/dev/null 2>&1 + command -v ab >/dev/null + command -v htpasswd >/dev/null + + conf=/usr/local/apache2/conf/httpd.conf + for directive in \ + "ServerName \${SERVER_NAME}" \ + "ServerTokens Prod" \ + "ServerSignature Off" \ + "TraceEnable Off" \ + "ProxyRequests Off" \ + "IncludeOptional conf/vhosts/*.conf"; do + test "$(grep -Fxc "$directive" "$conf")" -eq 1 + done + + before="$(sha256sum "$conf")" + /usr/local/bin/update_httpd.sh + once="$(sha256sum "$conf")" + /usr/local/bin/update_httpd.sh + twice="$(sha256sum "$conf")" + test "$before" = "$once" + test "$once" = "$twice" +' + +for vhost in "$tcp_vhost" "$socket_vhost"; do + docker run --rm --entrypoint sh \ + -v "$vhost:/usr/local/apache2/conf/vhosts/php.conf:ro" \ + "$image" -ec 'httpd -t' +done + +docker run --rm \ + -e SERVER_NAME=runtime.apache.test \ + -e APACHE_LOG_DIR=/tmp/apache-runtime-logs \ + "$image" sh -ec ' + test "$SERVER_NAME" = runtime.apache.test + test "$APACHE_LOG_DIR" = /tmp/apache-runtime-logs + test -d "$APACHE_LOG_DIR" + test -w "$APACHE_LOG_DIR" + httpd -t + ' + +docker run --rm -e TZ=Invalid/Zone "$image" sh -ec 'httpd -t' +docker run --rm "$image" sh -ec 'test "$(printf ok)" = ok' + +echo 'Apache configuration contracts passed.' diff --git a/tests/image-smoke.sh b/tests/image-smoke.sh new file mode 100755 index 0000000..be6c2c3 --- /dev/null +++ b/tests/image-smoke.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:-infocyph/apache:ci}" +name="apache-smoke-$$" + +cleanup() { + docker rm -f "$name" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +docker run -d --name "$name" "$image" >/dev/null + +for _ in $(seq 1 40); do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null || true)" + case "$status" in + healthy) break ;; + unhealthy) + docker inspect --format '{{json .State.Health}}' "$name" >&2 || true + docker logs "$name" >&2 || true + exit 1 + ;; + esac + sleep 1 +done + +[ "$(docker inspect --format '{{.State.Health.Status}}' "$name")" = healthy ] + +docker exec "$name" sh -ec ' + httpd -t + /usr/local/bin/healthcheck + command -v ab >/dev/null + command -v htpasswd >/dev/null + chromacat --version >/dev/null +' + +docker stop --time 10 "$name" >/dev/null +[ "$(docker inspect --format '{{.State.ExitCode}}' "$name")" -eq 0 ] + +echo 'Image smoke passed.' diff --git a/tests/mtls-smoke.sh b/tests/mtls-smoke.sh new file mode 100755 index 0000000..ffdc8da --- /dev/null +++ b/tests/mtls-smoke.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:-infocyph/apache:ci}" +network="apache-mtls-$$" +apache="apache-mtls-$$" +proxy="nginx-mtls-$$" +tmp="$(mktemp -d)" + +cleanup() { + docker rm -f "$proxy" "$apache" >/dev/null 2>&1 || true + docker network rm "$network" >/dev/null 2>&1 || true + rm -rf "$tmp" +} +trap cleanup EXIT INT TERM + +mkdir -p "$tmp/mkcert" "$tmp/rootCA" "$tmp/app" "$tmp/vhosts" +printf 'mtls-ok\n' > "$tmp/app/index.html" + +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj '/CN=LocalDevStack Test Root' \ + -keyout "$tmp/ca.key" \ + -out "$tmp/rootCA/rootCA.pem" >/dev/null 2>&1 + +openssl req -newkey rsa:2048 -nodes \ + -subj '/CN=apache.test' \ + -keyout "$tmp/mkcert/lds-server-key.pem" \ + -out "$tmp/server.csr" >/dev/null 2>&1 +printf '%s\n' 'subjectAltName=DNS:apache.test' 'extendedKeyUsage=serverAuth' > "$tmp/server.ext" +openssl x509 -req -days 1 \ + -in "$tmp/server.csr" \ + -CA "$tmp/rootCA/rootCA.pem" \ + -CAkey "$tmp/ca.key" \ + -CAcreateserial \ + -extfile "$tmp/server.ext" \ + -out "$tmp/mkcert/lds-server.pem" >/dev/null 2>&1 + +openssl req -newkey rsa:2048 -nodes \ + -subj '/CN=nginx-internal' \ + -keyout "$tmp/mkcert/lds-client-internal-key.pem" \ + -out "$tmp/client.csr" >/dev/null 2>&1 +printf '%s\n' 'extendedKeyUsage=clientAuth' > "$tmp/client.ext" +openssl x509 -req -days 1 \ + -in "$tmp/client.csr" \ + -CA "$tmp/rootCA/rootCA.pem" \ + -CAkey "$tmp/ca.key" \ + -CAcreateserial \ + -extfile "$tmp/client.ext" \ + -out "$tmp/mkcert/lds-client-internal.pem" >/dev/null 2>&1 + +cat > "$tmp/vhosts/ssl.conf" <<'EOF' + + ServerName apache.test + DocumentRoot /app + SSLEngine on + SSLCertificateFile /etc/mkcert/lds-server.pem + SSLCertificateKeyFile /etc/mkcert/lds-server-key.pem + SSLCACertificateFile /etc/share/rootCA/rootCA.pem + SSLVerifyClient require + SSLVerifyDepth 2 + SSLProtocol -all +TLSv1.2 +TLSv1.3 + Protocols h2 http/1.1 + + AllowOverride All + Require all granted + + +EOF + +cat > "$tmp/nginx.conf" </dev/null + +docker run -d --name "$apache" --network "$network" \ + -v "$tmp/app:/app:ro" \ + -v "$tmp/vhosts/ssl.conf:/usr/local/apache2/conf/vhosts/ssl.conf:ro" \ + -v "$tmp/mkcert:/etc/mkcert:ro" \ + -v "$tmp/rootCA:/etc/share/rootCA:ro" \ + "$image" >/dev/null + +for _ in $(seq 1 40); do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$apache" 2>/dev/null || true)" + [[ "$status" == healthy ]] && break + if [[ "$status" == unhealthy ]]; then + docker inspect --format '{{json .State.Health}}' "$apache" >&2 || true + docker logs "$apache" >&2 || true + exit 1 + fi + sleep 1 +done +[[ "$(docker inspect --format '{{.State.Health.Status}}' "$apache")" == healthy ]] + +docker run -d --name "$proxy" --network "$network" \ + -v "$tmp/nginx.conf:/etc/nginx/nginx.conf:ro" \ + -v "$tmp/mkcert:/etc/mkcert:ro" \ + -v "$tmp/rootCA:/etc/share/rootCA:ro" \ + nginx:alpine >/dev/null + +response='' +for _ in $(seq 1 30); do + if response="$(docker exec "$proxy" wget -qO- http://127.0.0.1:8080/ 2>/dev/null)"; then + break + fi + sleep 1 +done + +if [[ "$response" != 'mtls-ok' ]]; then + docker logs "$proxy" >&2 || true + docker logs "$apache" >&2 || true + echo "Unexpected mTLS response: $response" >&2 + exit 1 +fi + +echo 'Nginx -> Apache backend mTLS smoke passed.' diff --git a/tests/release-contract.sh b/tests/release-contract.sh new file mode 100755 index 0000000..f0c7901 --- /dev/null +++ b/tests/release-contract.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +workflow='.github/workflows/docker.publish.yml' + +for contract in \ + 'actions/checkout@v7' \ + 'docker/setup-qemu-action@v4' \ + 'docker/setup-buildx-action@v4' \ + 'docker/login-action@v4' \ + 'docker/metadata-action@v6' \ + 'docker/build-push-action@v7' \ + 'actions/attest@v4' \ + 'PUBLISH_RELEASE_TAG' \ + 'Enforce immutable release tags' \ + 'linux/amd64,linux/arm64' \ + 'provenance: mode=max' \ + 'sbom: true'; do + grep -Fq "$contract" "$workflow" +done + +grep -Fq "cron: '0 0 * * 0'" "$workflow" + +if grep -Fq 'actions/checkout@v4' "$workflow"; then + echo 'Legacy checkout action detected.' >&2 + exit 1 +fi +if grep -Fq 'docker/login-action@v3' "$workflow"; then + echo 'Legacy Docker login action detected.' >&2 + exit 1 +fi + +echo 'Release workflow contracts passed.' diff --git a/tests/release-gate.sh b/tests/release-gate.sh new file mode 100755 index 0000000..061aaaf --- /dev/null +++ b/tests/release-gate.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:-infocyph/apache:ci}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +bash tests/image-smoke.sh "$image" +bash tests/httpd-config.sh "$image" +bash tests/healthcheck.sh "$image" +bash tests/mtls-smoke.sh "$image" + +echo 'Apache release gate passed.' diff --git a/tests/static.sh b/tests/static.sh new file mode 100755 index 0000000..e614061 --- /dev/null +++ b/tests/static.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +fail() { + printf 'Static contract failed: %s\n' "$*" >&2 + exit 1 +} + +for script in scripts/*.sh; do + sh -n "$script" +done + +for test_script in tests/*.sh; do + bash -n "$test_script" +done + +shellcheck scripts/*.sh tests/*.sh + +grep -Fq 'FROM httpd:alpine' Dockerfile +grep -Fq 'apk upgrade --no-cache' Dockerfile +grep -Fq 'apache2-utils' Dockerfile +if grep -Eq '^[[:space:]]+apache-mod-fcgid([[:space:]\\;]|$)' Dockerfile; then + fail 'apache-mod-fcgid must not be installed' +fi +if grep -Eq '^[[:space:]]+apache2([[:space:]\\;]|$)' Dockerfile; then + fail 'Alpine apache2 server package must not be installed' +fi +grep -Fq 'Scriptomatic/main/bash/banner.sh' Dockerfile +grep -Fq 'Toolset/releases/latest/download/install.sh' Dockerfile +helper_downloads="$(grep -c -- '--connect-timeout 10 --max-time 120' Dockerfile)" +if [[ "$helper_downloads" -ne 2 ]]; then + fail 'Both helper downloads must use bounded connect and total timeouts' +fi +grep -Fq -- '--retry-all-errors' Dockerfile + +grep -Fq "ServerName \${SERVER_NAME}" scripts/update_httpd.sh +for directive in \ + 'ServerTokens Prod' \ + 'ServerSignature Off' \ + 'TraceEnable Off' \ + 'ProxyRequests Off' \ + 'IncludeOptional conf/vhosts/*.conf'; do + grep -Fq "$directive" scripts/update_httpd.sh +done + +if grep -Fq 'conf/extra' scripts/update_httpd.sh; then + fail 'update_httpd.sh must not rewrite broad upstream conf/extra configuration' +fi +if grep -Fq "rm -f -- \"\$0\"" scripts/update_httpd.sh; then + fail 'update_httpd.sh must remain available in the final image' +fi + +grep -Fq 'exec "$@"' scripts/entrypoint.sh +grep -Fq '127.0.0.1' scripts/healthcheck.sh +grep -Fq -- '--connect-timeout' scripts/healthcheck.sh +grep -Fq -- '--max-time' scripts/healthcheck.sh + +grep -Fq 'infocyph/apache:latest' README.md +if grep -Fq 'infocyph/docker-apache:' README.md; then + fail 'README must use the published infocyph/apache image name' +fi +grep -Fq "\`SERVER_NAME\`" README.md +grep -Fq "\`APACHE_LOG_DIR\`" README.md +grep -Fq "\`apache2-utils\`" README.md + +bash tests/release-contract.sh + +echo 'Static checks passed.'