diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a8a6b5..958e9d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,28 @@ name: CI +# Runs on every branch and pull request. Publishing lives in release.yml and +# only runs once this workflow has passed. on: push: - branches: [main] + branches: ['**'] pull_request: + branches: [main] + workflow_call: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: lang-lint: + name: Lint de idioma (R9) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: node scripts/lang-lint.mjs go-protocols: + name: Go (protocols) runs-on: ubuntu-latest services: postgres: @@ -49,6 +59,7 @@ jobs: TEST_DATABASE_URL: postgres://lambdamail_test:lambdamail_test@localhost:5432/lambdamail_test?sslmode=disable ts-auth: + name: TypeScript (auth) runs-on: ubuntu-latest defaults: run: @@ -62,6 +73,7 @@ jobs: - run: npm test ts-storage: + name: TypeScript (storage) runs-on: ubuntu-latest defaults: run: @@ -75,6 +87,7 @@ jobs: - run: npm test webmail-build: + name: Build (webmail) runs-on: ubuntu-latest defaults: run: @@ -86,12 +99,70 @@ jobs: - run: npm install - run: npm run build + security: + name: Trivy (dependências, segredos e Dockerfiles) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # A mail server holds credentials for every mailbox it serves, so the + # secret scanner matters as much as the vulnerability one here: a DKIM + # key or a Cloudflare token committed by accident is a full compromise. + - name: Repositório + uses: aquasecurity/trivy-action@v0.36.0 + with: + scan-type: fs + scanners: vuln,secret,misconfig + severity: HIGH,CRITICAL + exit-code: '1' + skip-dirs: services/auth/node_modules,services/storage/node_modules,services/webmail/node_modules,services/webmail/.next + + image: + name: Imagem (${{ matrix.service }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + service: [protocols, auth, storage, webmail] + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + # Built but not pushed: this proves each Dockerfile still works on every + # branch, while publishing stays in release.yml. + - name: Build + uses: docker/build-push-action@v6 + with: + context: services/${{ matrix.service }} + push: false + load: true + tags: lambdamail-${{ matrix.service }}:ci + cache-from: type=gha,scope=${{ matrix.service }} + cache-to: type=gha,mode=max,scope=${{ matrix.service }} + + - name: Trivy na imagem + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: lambdamail-${{ matrix.service }}:ci + severity: HIGH,CRITICAL + exit-code: '1' + compose-up: + name: Stack completa runs-on: ubuntu-latest needs: [go-protocols, ts-auth, ts-storage, webmail-build] steps: - uses: actions/checkout@v4 - run: cp .env.example .env + # The proxy network is declared external because Coolify owns it on the + # deploy host. Nothing creates it here, and without it compose refuses to + # start the stack at all. + - name: Cria a rede do proxy + run: docker network create "${COOLIFY_PROXY_NETWORK:-coolify}" + # --build makes compose build each service locally and tag it under the + # same name the published image uses, so the stack comes up without ever + # reaching the registry. - run: docker compose --env-file .env up --build --wait - run: docker compose ps - run: docker compose down -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..20d062e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,179 @@ +name: Release + +# Publishes the four service images to GHCR after a merge into main, and again +# under semver tags when a v* tag is pushed. The deploy host only pulls, so +# nothing has to be built on the server. +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + +jobs: + # Nothing is published unless the full suite passes first. + ci: + uses: ./.github/workflows/ci.yml + + # GHCR rejects an uppercase path, and this repository is "lucasrguerra/LambdaMail". + # Lowercasing github.repository here keeps the workflow fork-safe instead of + # hardcoding the owner. + prepare: + name: Namespace das imagens + runs-on: ubuntu-latest + outputs: + namespace: ${{ steps.namespace.outputs.value }} + steps: + - id: namespace + run: echo "value=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + # Each architecture is built on a runner of its own kind. QEMU emulation + # would also work, but it makes the Go and Next builds roughly an order of + # magnitude slower. + build: + name: Build ${{ matrix.service }} (${{ matrix.platform }}) + needs: [ci, prepare] + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + # Both are axes, so this is a cross product: four services on two + # architectures is eight jobs. Declaring platform only under `include` + # would not multiply anything - include can add keys to existing + # combinations, but it cannot create the combinations themselves. + service: [protocols, auth, storage, webmail] + platform: [linux/amd64, linux/arm64] + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Autentica no GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Metadados + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }} + + # Pushed by digest only: the tags are attached later, once both + # architectures exist and can be joined into a single manifest list. + - name: Build e push por digest + id: build + uses: docker/build-push-action@v6 + with: + context: services/${{ matrix.service }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ matrix.service }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ matrix.arch }} + + - name: Exporta o digest + run: | + mkdir -p /tmp/digests + # The output already carries the `sha256:` prefix; the file name must + # not, since the merge step prepends it again. + digest='${{ steps.build.outputs.digest }}' + touch "/tmp/digests/${digest#sha256:}" + + - name: Guarda o digest + uses: actions/upload-artifact@v4 + with: + # Slashes are not allowed in artifact names, hence the arch alias. + name: digest-${{ matrix.service }}-${{ matrix.arch }} + path: /tmp/digests/* + retention-days: 1 + if-no-files-found: error + + publish: + name: Publica ${{ matrix.service }} + needs: [build, prepare] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + service: [protocols, auth, storage, webmail] + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Autentica no GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Recupera os digests + uses: actions/download-artifact@v4 + with: + pattern: digest-${{ matrix.service }}-* + merge-multiple: true + path: /tmp/digests + + # `latest` is what compose pulls. The semver tags only materialise on a + # v* tag push and make it possible to pin or roll back to an exact + # release; the sha tag does the same for any main build. + - name: Metadados + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + type=sha,format=long + + # Joins both architectures under one manifest list, so `docker pull` + # resolves to the right one automatically. + - name: Cria o manifesto multi-arquitetura + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') \ + $(printf '${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}@sha256:%s ' *) + + - name: Confere o resultado + run: | + docker buildx imagetools inspect \ + "${{ env.REGISTRY }}/${{ needs.prepare.outputs.namespace }}/${{ matrix.service }}:latest" \ + --format '{{json .Manifest}}' \ + | jq -r '.manifests[] | select(.platform.os == "linux") | "\(.platform.os)/\(.platform.architecture)"' + + - name: Resumo + run: | + { + echo "### ${{ matrix.service }} publicado (linux/amd64 e linux/arm64)" + echo + echo '```' + echo "${{ steps.meta.outputs.tags }}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/services/auth/Dockerfile b/services/auth/Dockerfile index e2f95cf..908ddb7 100644 --- a/services/auth/Dockerfile +++ b/services/auth/Dockerfile @@ -1,14 +1,36 @@ FROM node:20-alpine AS build WORKDIR /app COPY package.json package-lock.json* ./ -RUN npm install +# npm ci, not npm install: the image must be the lockfile's content, not +# whatever resolves the day it is built. +RUN npm ci COPY . . RUN npm run build -FROM node:20-alpine +# Production dependencies only. The previous image copied the build stage's +# node_modules wholesale, shipping typescript, eslint and vitest into +# production - most of the CVEs the image scan reported came from that +# tooling, which never runs in the container. +FROM node:20-alpine AS prod-deps WORKDIR /app -COPY --from=build /app/node_modules ./node_modules +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +FROM node:20-alpine +# The node:20-alpine tag lags its Alpine base, so pending security patches +# (openssl, busybox) land only with an explicit upgrade. +RUN apk upgrade --no-cache \ + # The runtime only ever runs "node"; npm is not invoked and its own + # bundled dependency tree (sigstore, cross-spawn, glob, ...) is what + # every remaining image CVE came from. Removing it drops the whole + # class instead of chasing each advisory. + && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx +WORKDIR /app +COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ + +# The node images ship an unprivileged "node" user (uid 1000). +USER node EXPOSE 3001 CMD ["node", "dist/server.js"] diff --git a/services/protocols/Dockerfile b/services/protocols/Dockerfile index c4ba5d9..8f3fcfb 100644 --- a/services/protocols/Dockerfile +++ b/services/protocols/Dockerfile @@ -6,7 +6,26 @@ COPY . . RUN CGO_ENABLED=0 go build -o /out/lambdamail-protocols ./cmd/lambdamail-protocols FROM alpine:3.20 -RUN apk add --no-cache ca-certificates +# libcap provides setcap and is dropped again once the capability is set. +RUN apk add --no-cache ca-certificates libcap + COPY --from=build /out/lambdamail-protocols /app/lambdamail-protocols + +# This process terminates SMTP from the open internet, so it must not run as +# root (PLAN.md section 17, risk R5). Every mail port it binds is below 1024, +# which normally requires root - the file capability grants exactly that one +# privilege to this one binary instead. +RUN setcap 'cap_net_bind_service=+ep' /app/lambdamail-protocols \ + && apk del libcap + +RUN addgroup -g 10001 -S lambdamail \ + && adduser -u 10001 -S -G lambdamail lambdamail \ + # Created here so a fresh named volume mounted at this path inherits the + # ownership: Docker seeds an empty volume from the image directory. + && mkdir -p /var/lambdamail/spool \ + && chown -R lambdamail:lambdamail /var/lambdamail + +USER lambdamail:lambdamail + EXPOSE 8080 ENTRYPOINT ["/app/lambdamail-protocols"] diff --git a/services/storage/Dockerfile b/services/storage/Dockerfile index a758cee..96ccdf8 100644 --- a/services/storage/Dockerfile +++ b/services/storage/Dockerfile @@ -1,14 +1,40 @@ FROM node:20-alpine AS build WORKDIR /app COPY package.json package-lock.json* ./ -RUN npm install +# npm ci, not npm install: the image must be the lockfile's content, not +# whatever resolves the day it is built. +RUN npm ci COPY . . RUN npm run build -FROM node:20-alpine +# Production dependencies only. The previous image copied the build stage's +# node_modules wholesale, shipping typescript, eslint and vitest into +# production - most of the CVEs the image scan reported came from that +# tooling, which never runs in the container. +FROM node:20-alpine AS prod-deps WORKDIR /app -COPY --from=build /app/node_modules ./node_modules +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +FROM node:20-alpine +# The node:20-alpine tag lags its Alpine base, so pending security patches +# (openssl, busybox) land only with an explicit upgrade. +RUN apk upgrade --no-cache \ + # The runtime only ever runs "node"; npm is not invoked and its own + # bundled dependency tree (sigstore, cross-spawn, glob, ...) is what + # every remaining image CVE came from. Removing it drops the whole + # class instead of chasing each advisory. + && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx +WORKDIR /app +COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ + +# Created before dropping privileges so a fresh named volume mounted here +# inherits the ownership: Docker seeds an empty volume from the image. +RUN mkdir -p /var/lambdamail/mails && chown -R node:node /var/lambdamail + +# The node images ship an unprivileged "node" user (uid 1000). +USER node EXPOSE 3002 CMD ["node", "dist/server.js"] diff --git a/services/webmail/Dockerfile b/services/webmail/Dockerfile index 4da61b9..d16f193 100644 --- a/services/webmail/Dockerfile +++ b/services/webmail/Dockerfile @@ -1,15 +1,28 @@ FROM node:20-alpine AS build WORKDIR /app COPY package.json package-lock.json* ./ -RUN npm install +RUN npm ci COPY . . RUN npm run build FROM node:20-alpine +# The node:20-alpine tag lags its Alpine base, so pending security patches +# (openssl, busybox) land only with an explicit upgrade. +RUN apk upgrade --no-cache \ + # The runtime only ever runs "node"; npm is not invoked and its own + # bundled dependency tree (sigstore, cross-spawn, glob, ...) is what + # every remaining image CVE came from. Removing it drops the whole + # class instead of chasing each advisory. + && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx WORKDIR /app -COPY --from=build /app/.next/standalone ./ -COPY --from=build /app/.next/static ./.next/static -COPY --from=build /app/public ./public +# --chown matters here: Next's standalone server writes its cache under +# .next at runtime, which it cannot do if the tree belongs to root. +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static +COPY --from=build --chown=node:node /app/public ./public + +# The node images ship an unprivileged "node" user (uid 1000). +USER node EXPOSE 3000 # Docker sets HOSTNAME to the container ID by default; Next's standalone # server.js honors that env var and binds only to it, refusing localhost diff --git a/services/webmail/package-lock.json b/services/webmail/package-lock.json index 93a57bb..c3bd744 100644 --- a/services/webmail/package-lock.json +++ b/services/webmail/package-lock.json @@ -140,9 +140,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -159,9 +156,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -178,9 +172,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -197,9 +188,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -216,9 +204,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -235,9 +220,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -254,9 +236,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -273,9 +252,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -292,9 +268,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -317,9 +290,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -342,9 +312,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -367,9 +334,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -392,9 +356,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -417,9 +378,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -442,9 +400,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -467,9 +422,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -622,9 +574,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -641,9 +590,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -660,9 +606,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -679,9 +622,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -898,9 +838,9 @@ "license": "ISC" }, "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -917,9 +857,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" diff --git a/services/webmail/package.json b/services/webmail/package.json index 98336d6..7e71b98 100644 --- a/services/webmail/package.json +++ b/services/webmail/package.json @@ -18,6 +18,7 @@ "@types/react": "^18.3.3" }, "overrides": { - "sharp": "^0.35.0" + "sharp": "^0.35.0", + "postcss": "^8.5.25" } }