diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..b3139ac --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,76 @@ +name: Build and Publish CMS Image + +# Builds the CMS backend Docker image and pushes it to GitHub Container Registry +# (GHCR). Pushes are tagged with the full commit SHA (immutable, used for +# production deploys) and, on the default branch, a moving `latest` tag. +# Pull requests build the image to catch breakage but never push. + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + branches: + - main + workflow_dispatch: + +# Only the latest run per ref needs to finish; cancel superseded ones. +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # GHCR image names must be lowercase; the org (DrexelTriangle) is not. + - name: Compute lowercase image name + id: image + run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Skip login on pull_request builds: fork PRs have no push rights and + # must not receive registry credentials. + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive image tags and labels + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=sha,format=long,prefix= + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=tag + labels: | + org.opencontainers.image.title=triangle-cms + org.opencontainers.image.source=https://github.com/${{ github.repository }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./server + file: ./server/Dockerfile + # Push on branch/tag builds and manual runs; PRs build only. + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 8d34732..580246a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ /.env frontend/.env server/data/ +..env.un~ diff --git a/docker-compose.yml b/docker-compose.yml index 4323786..8c01292 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,6 +58,10 @@ services: - "8080:8080" volumes: - cms_activity_data:/app/data + # TLS certs are mounted at runtime (not baked into the image). Generate + # local dev certs with scripts/generate_certs.sh. In production these are + # provided by the host / replaced by Nginx TLS termination. + - ./server/certs:/app/certs:ro networks: - triangle_net diff --git a/scripts/generate_certs.sh b/scripts/generate_certs.sh new file mode 100755 index 0000000..4d99092 --- /dev/null +++ b/scripts/generate_certs.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Generate a self-signed TLS certificate for local development. +# +# TLS certs are intentionally NOT committed to git or baked into the Docker +# image. Each developer / server generates its own. docker-compose mounts +# ./server/certs into the CMS container at runtime. +# +# Usage: +# scripts/generate_certs.sh # generate if missing +# scripts/generate_certs.sh --force # overwrite existing certs +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cert_dir="${repo_root}/server/certs" +crt="${cert_dir}/localhost.crt" +key="${cert_dir}/localhost.key" + +force=false +if [[ "${1:-}" == "--force" ]]; then + force=true +fi + +if [[ -f "$crt" && -f "$key" && "$force" != true ]]; then + echo "Certs already exist at ${cert_dir} (use --force to overwrite)." + exit 0 +fi + +mkdir -p "$cert_dir" + +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$key" \ + -out "$crt" \ + -days 365 \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + +chmod 600 "$key" +echo "Generated self-signed dev cert:" +echo " ${crt}" +echo " ${key}" diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..d47b1f0 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,11 @@ +# Keep secrets and machine-specific state out of the build context / image. +certs/ +.env +.env.* +data/ +cover.out +report.json + +# Editor / VCS noise +*.un~ +.git diff --git a/server/Dockerfile b/server/Dockerfile index db908c9..bda9bf3 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -13,7 +13,10 @@ FROM alpine:3.21 WORKDIR /app COPY --from=builder /bin/cms /app/cms -COPY certs /app/certs + +# TLS certificates are NOT baked into the image. They are environment-specific +# secrets and are provided at runtime (bind mount / Nginx termination). See +# docker-compose.yml and scripts/generate_certs.sh. EXPOSE 8080 ENTRYPOINT ["/app/cms"] diff --git a/server/certs/localhost.crt b/server/certs/localhost.crt deleted file mode 100644 index a956ff1..0000000 --- a/server/certs/localhost.crt +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDJTCCAg2gAwIBAgIUV2c+xom8gBZKVSEVll07sjXFFSAwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDMxNDIyMDYwOVoXDTI3MDMx -NDIyMDYwOVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAs6JUBoH6AUkWIlTUrbOqpcQfjHPpaV2hrlCDxylvnob6 -qMpiFZKHD9paO6UtHLJIA0CSXLel9T23CyGgq0LRsrE1M4OR/Rk4zegQK9yLJj4q -pNnQiRgLq5Mgm0v+YR3xbJuyF4LVR1KNCTDjOsVL9R1L1Fls5HHn1/deHQvhxnKO -CBnWVPehbZbJWEtlsvGtRSMwO6z+7IjiBX8W6fcQbTdI1y0+FEh7Us4jCtS4iXsx -yEADg8WOISE+daRmKXAbPjKPFV3jgEA8vtsy/XYJeYCaIl/xI7LObDu60hqOZOJR -pW6TbKYBzdVbMVFtObAIzmAonfKsvC8/xBRibS9neQIDAQABo28wbTAdBgNVHQ4E -FgQUQfQDPYgupBKgGjmijVst+ePziX8wHwYDVR0jBBgwFoAUQfQDPYgupBKgGjmi -jVst+ePziX8wDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH -BH8AAAEwDQYJKoZIhvcNAQELBQADggEBAK9Wl2z5tM/+lTHvAmXESLYhnQVe536w -bdvV8Y4+wLHQKvKF0Bdwkq9Ix++YnnCrRK6TngUiIyeDCD5BtW//42KhMrLnNy5N -zr+95wpYJ9iY1ZKkP1xu9md28blUy+MCG04go92lTyHpV9AJPvYZcxQebzBGaytn -FblFFtF+5l16NTjEi5U+6VYnFZPtfVWmMORLpfCDrkfLVfgsgNIENTU71Z43KhrH -5NK1BBKjcrhHqDyQmcmsivA+rT8L13UD6SUBOu5qI5vCta40GEzThJOFHloh5Rc4 -fJul2JhI32MpiN4T2KMKSSyjCMavnjmVml020tLJ2HZqBkwryaaA25o= ------END CERTIFICATE----- diff --git a/server/certs/localhost.key b/server/certs/localhost.key deleted file mode 100644 index 7b07999..0000000 --- a/server/certs/localhost.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCzolQGgfoBSRYi -VNSts6qlxB+Mc+lpXaGuUIPHKW+ehvqoymIVkocP2lo7pS0cskgDQJJct6X1PbcL -IaCrQtGysTUzg5H9GTjN6BAr3IsmPiqk2dCJGAurkyCbS/5hHfFsm7IXgtVHUo0J -MOM6xUv1HUvUWWzkcefX914dC+HGco4IGdZU96FtlslYS2Wy8a1FIzA7rP7siOIF -fxbp9xBtN0jXLT4USHtSziMK1LiJezHIQAODxY4hIT51pGYpcBs+Mo8VXeOAQDy+ -2zL9dgl5gJoiX/Ejss5sO7rSGo5k4lGlbpNspgHN1VsxUW05sAjOYCid8qy8Lz/E -FGJtL2d5AgMBAAECggEAEjBhqbcHhZHM9/T3BA+pO9e8ChDClbSxZmEotsXvU3H/ -d4/mlqCbpHNLp7Z19u3mdM3ZBCJkX4y8OBhTmIPPj/R5mpm78pDXO6I/4PEX9etd -jguwHfGdXOYs6wyq2VKa25B4XAEXb31gYoncuFq58jvhLcNQT2FpRGj70p0dY2aS -pYOQQ7jq0zqnfiE6vNyBp6L1ouGoUKh2U74X+jBC6d53klKsSlVMLRDfxF90sSV8 -e0tmQCtylP4xMQookbamWye6EG1CriKwz4MI0z0GemFmHlUdZsY8bvsS57f6mK7j -9F9qAHPjQzrTrDoQOdabzmErMFti2PjTBtco8odUMQKBgQDXEQRoKEilG85eKP2W -7kkiqHOpTGvtVwG1YY63SNfq4Z7n/YYJzH5WvpiRlLL0ja7hRyfI86JaRzaryi/o -1vHpMlhvlECX+XYY1G/GRyuGdKbLNKECLMTQY81gRg1gUZH3szEF1BjUo8VEvlZ8 -hEsa5t8pANREgbeW8dm+25dL2wKBgQDV0uPUC+ycfMc7yAr1Q9a8wdnCRdlnSoDj -BtXX02reygmWDn6smnA83mRKvUx34rdD+Z1+KthBkVxeiuu1fvbc87MJH53em2oL -wiaW58DNHE+HB9qQr3+Je2kwUBFpTgxuMHOfz0IxXV4OdiddMTIZEqxMjjSoj9A7 -vLIVOtyEOwKBgQC+rXbDxhuKT5nrP9p+C6xKDyHk0z3JQt/f6av38pyEwh76Pnut -imHlFLz+LeFwE4Ied9DdSK0D76mYK/3IKXJ3t8xSuRV/6Iv9PbQQDp+XaStMdM/f -tsOUoze+iL1pTUjmXTg0m7ScKstGxJ63bS+NnW3z06lfR8b3DFxq7BQ7CwKBgQCb -P+Prsjy54l9GKJjMO9VOd/GD/PM86/to2y2o5vhRiqArM5sF95xGlbJ8EbXT1ej6 -keKoGLOr578JZhR+UxEqnEcB+NLHLlBw+SbLqrlyM3wKrskjBEm1qMVXLst1ihW8 -a7GH9xQ4g3InTeKG55FYYxZ2/0l8me63cOpcP8CcwQKBgQDMKUVLiikCjN/AbJ7M -gkmU96PkRYXrVp7t7zrkGPWxzpltroCkNteWwFLtNgtGdCu7dNlaLxJw/oHzAA+q -QlBJCdytz95kqD0alLF2CRqLFzDGx98AJlbTTZwa8gFJk5BbiDy59k4mOBtsLcDZ -MGqYVKrdWoWt0BZTN2bq5SrK/w== ------END PRIVATE KEY-----