diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 4436a30..63197c1 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -87,6 +87,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Pull image tag on EC2 and restart + id: deploy uses: appleboy/ssh-action@v1.2.5 env: SERVER_IMAGE: ${{ needs.build-and-push.outputs.image }} @@ -106,6 +107,10 @@ jobs: aws ecr get-login-password --region "$AWS_REGION" \ | docker login --username AWS --password-stdin "$ECR_REGISTRY" cd ~/ameet + # Record the currently-running image BEFORE switching, so a failed + # post-deploy health check can roll back to the last-good release. + # Empty file (|| true) means "no prior container" — a first deploy. + docker inspect --format '{{.Config.Image}}' a-meet-server > .rollback-image 2>/dev/null || true # Check out the EXACT commit that produced SERVER_IMAGE (detached HEAD) # rather than moving `main`, so the compose file + env contract used to # deploy always match the image tag being released — even if a newer @@ -120,8 +125,9 @@ jobs: docker image prune -f - name: Health check + id: health run: | - HEALTH_URL="${{ vars.HEALTH_URL || 'https://api.ameet.raja-dev.me/api/health' }}" + HEALTH_URL="${{ vars.HEALTH_URL || 'https://api.ameet.raja-dev.me/api/health/ready' }}" for i in $(seq 1 12); do if curl -fsS "$HEALTH_URL" | grep -q '"ok":true'; then echo "Health check passed (attempt $i)" @@ -132,3 +138,59 @@ jobs: done echo "Health check failed: API did not return ok:true after 60s" exit 1 + + # Automated rollback: if either the deploy step OR the post-deploy health + # check failed, redeploy the image that was running before this release. + # Because every build is an immutable : tag, rollback is just + # re-running the deploy against the prior tag recorded above in + # .rollback-image. The deploy step can fail *after* `docker compose up -d` + # has already stopped/replaced the old container (e.g. the new image starts + # but a later command errors), so a deploy-step failure can leave the box + # without the last-good release — we must roll back there too, not only on + # a health-check failure. The rollback re-checkout uses the SHA recorded in + # .rollback-image; if none exists (first deploy) it exits red for a human. + # This step restores service but the job still ends red, so the existing + # CloudWatch/Telegram alarm path still notifies a human. + - name: Roll back to previous image on failed deploy or health check + if: ${{ failure() && (steps.deploy.outcome == 'failure' || steps.health.outcome == 'failure') }} + uses: appleboy/ssh-action@v1.2.5 + env: + ECR_REGISTRY: ${{ needs.build-and-push.outputs.registry }} + AWS_REGION: ${{ vars.AWS_REGION }} + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + command_timeout: 10m + envs: ECR_REGISTRY,AWS_REGION + script: | + set -euo pipefail + cd ~/ameet + PREV_IMAGE="$(cat .rollback-image 2>/dev/null || true)" + if [ -z "$PREV_IMAGE" ]; then + echo "No previous image recorded (first deploy?) — cannot auto-roll back." + exit 1 + fi + echo "Rolling back to previous image: $PREV_IMAGE" + aws ecr get-login-password --region "$AWS_REGION" \ + | docker login --username AWS --password-stdin "$ECR_REGISTRY" + # The image tag is the prior commit SHA; check that commit out so the + # compose file + env contract match the image being restored. + PREV_SHA="${PREV_IMAGE##*:}" + git fetch --quiet origin main + git checkout --quiet --force --detach "$PREV_SHA" + export SERVER_IMAGE="$PREV_IMAGE" + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d + docker image prune -f + # Confirm the restored container is actually serving before we stop. + for i in $(seq 1 12); do + if curl -fsS http://localhost:5000/api/health/ready | grep -q '"ok":true'; then + echo "Rollback healthy (attempt $i)" + exit 0 + fi + echo "Attempt $i: rolled-back API not healthy yet, retrying in 5s..." + sleep 5 + done + echo "Rollback deployed but health still failing after 60s — needs a human." + exit 1 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 4006ba6..2f5a164 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -1,9 +1,9 @@ name: Typecheck # Runs the strict TypeScript type-checker across all workspaces on every PR and -# on pushes to main. While the codebase is still mostly JavaScript this only -# checks the shared contracts and each package's migrated TypeScript (allowJs is -# on, checkJs is off), so it stays green until modules are converted leaf-up. +# on pushes to main. It checks the shared contracts and each package's migrated +# TypeScript (allowJs is on, checkJs is off), so it stays green while any +# remaining JavaScript modules are converted leaf-up. on: push: branches: [main] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4729b4f..754c9d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ migration) because it only breaks when *behavior* breaks. **Prior art — match this style:** -- `client/src/components/VideoTile.test.jsx` — opens the volume menu and asserts on the +- `client/src/components/VideoTile.test.tsx` — opens the volume menu and asserts on the rendered result without reaching into or mutating call state. - `server/test/sfu-handlers-authz.test.js` — the capture-and-invoke pattern: register the handlers, capture the socket callbacks, invoke them, and assert the *effect* (host action @@ -67,6 +67,6 @@ npm run verify coverage ratchet, and the `Playwright smoke`). A green `verify` means those gates are satisfied. It does **not** run the path-scoped `Server image smoke` job (a ~15-min Docker build of the production image that spawns a real mediasoup worker) — CI only runs that when server-image files change -(`server/src/**`, `server/Dockerfile`, `server/.dockerignore`, `server/package*.json`, -`docker-compose.prod.yml`). +(`server/src/**`, `server/Dockerfile`, `.dockerignore`, `server/package.json`, `shared/**`, +`package.json`, `package-lock.json`, `docker-compose.prod.yml`). The `test:e2e:install` step is a one-time prerequisite — it is *not* part of `npm ci`. diff --git a/README.md b/README.md index df5367b..05fa6a1 100644 --- a/README.md +++ b/README.md @@ -103,11 +103,12 @@ npm --prefix server install ### 2. Configure environment -Two env files, with distinct jobs: +Three env files, with distinct jobs: ```bash cp .env.example .env # repo root — local Docker Mongo credentials (read by Compose) cp server/.env.example server/.env # server app config (read by the Node server) +cp client/.env.example client/.env # client app config (Vite reads VITE_SERVER_URL for Socket.io) ``` `docker compose` reads the **repo-root `.env`** for the Mongo container credentials, so those @@ -115,6 +116,10 @@ live there — not in `server/.env`. Keep the two in sync: the username/password `.env` must match the ones embedded in the server's `MONGO_URI` (both default to `admin` / `change-me`). +The **`client/.env`** supplies `VITE_SERVER_URL` — the origin the browser opens the Socket.io +connection to. There is no built-in fallback, so on a fresh clone signaling silently fails to +connect until this file exists; the default points at the local server (`http://localhost:5000`). + Repo-root `.env` (local Docker Mongo only — unused in production): ```env @@ -235,7 +240,7 @@ A-Meet/ │ └── src/ │ ├── routes/ # auth, meetings, rooms │ ├── socket/ # room events, SFU signalling -│ ├── models/ # User, Meeting +│ ├── models/ # User, Room │ └── middleware/ # JWT cookie auth ├── docker-compose.yml # LOCAL DEV: MongoDB + observability stack ├── docker-compose.prod.yml # PRODUCTION: server only (DB is Atlas via MONGO_URI) @@ -297,7 +302,7 @@ git clone ... && cd A-Meet # the node keeps a checkout for config (compose fil # Runtime secrets are loaded from SSM by the container entrypoint. No production # server/.env is required or copied into the image. -# Open UDP ports 10000–59999 in the security group (mediasoup RTP range), plus 5000 (API). +# Open UDP ports 40000–40100 in the security group (mediasoup RTP range), plus 5000 (API). # Pull the published image tag and start (this is what CI automates on each deploy): export SERVER_IMAGE=.dkr.ecr..amazonaws.com/a-meet-server: @@ -328,7 +333,7 @@ cleanly, so merges to `main` stay green. To enable it: |---|---| | `AWS_REGION` | ECR / deploy region, e.g. `ap-south-1` | | `ECR_REPOSITORY` | ECR repo name, e.g. `a-meet-server` (must exist) | -| `HEALTH_URL` | optional; defaults to `https://api.ameet.raja-dev.me/api/health` | +| `HEALTH_URL` | optional; defaults to `https://api.ameet.raja-dev.me/api/health/ready` | **Repository secrets:** @@ -471,9 +476,9 @@ the instance isn't `ebs`-backed. That makes it safe to use as an automated post- **Operator validation after a recovery event:** 1. `deploy/aws-recovery.sh verify` — EIP still associated to the instance; alarm back to `OK`; root device `ebs`. -2. **API health:** `curl -fsS https://api./api/health` returns `{"ok":true}` (the deploy health check uses the same endpoint). +2. **API health:** `curl -fsS https://api./api/health/ready` returns `{"ok":true}` (the deploy health check uses the same readiness endpoint). 3. **Container up:** on the box, `docker compose -f docker-compose.prod.yml ps` shows the server running, and `logs -f` shows mediasoup workers started. -4. **Media connectivity:** join a meeting from two devices and confirm audio/video flows — i.e. `MEDIASOUP_ANNOUNCED_IP` still equals the EIP and the security-group UDP RTC range (10000–59999) is open. +4. **Media connectivity:** join a meeting from two devices and confirm audio/video flows — i.e. `MEDIASOUP_ANNOUNCED_IP` still equals the EIP and the security-group UDP RTC range (40000–40100) is open. For HTTPS (required for camera/mic on non-localhost), put Nginx in front with a Let's Encrypt cert and proxy to `localhost:5000`. diff --git a/client/package.json b/client/package.json index 16dac00..f7b5734 100644 --- a/client/package.json +++ b/client/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": ">=20.19.0" + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 5861368..aa9b464 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -24,6 +24,14 @@ server { ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; + # Security headers at the TLS edge. Applied here (with `always`) so they also + # cover nginx-generated responses — the port-80 301 redirect and the catch-all + # 404 — which never reach the Express/helmet upstream. helmet already emits + # both on proxied /api and /socket.io responses, so those locations strip the + # upstream copies (proxy_hide_header below) to avoid duplicate headers. + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + location /api/ { proxy_pass http://localhost:5000; proxy_http_version 1.1; @@ -31,6 +39,9 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # Drop helmet's duplicates; the edge add_header above provides the single copy. + proxy_hide_header Strict-Transport-Security; + proxy_hide_header X-Content-Type-Options; } location /socket.io/ { @@ -41,6 +52,13 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_cache_bypass $http_upgrade; + # WebSocket connections are long-lived and mostly idle between events; + # the default 60s proxy_read_timeout would drop them. Hold them open well + # past Socket.io's ping interval so the upgrade isn't severed mid-call. + proxy_read_timeout 3600s; + # Same duplicate-stripping as /api/ (helmet runs on every upstream response). + proxy_hide_header Strict-Transport-Security; + proxy_hide_header X-Content-Type-Options; } # Catch-all: any path not matched above returns 404 (no static files on EC2) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index eefaccd..f23224e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -38,6 +38,30 @@ services: # Auto-restart on crash/reboot; the server drains gracefully on the SIGTERM # Docker sends, so restarts don't drop in-flight media abruptly. restart: unless-stopped + # Resource ceilings so a runaway process (leaked producers, a busy call) + # can't starve nginx/system on the box. Honored by `docker compose up` + # (non-swarm). Sized for a small single node (~2 vCPU / 2-4 GB); raise on a + # larger instance. Reservation keeps a floor for steady-state mediasoup work. + mem_limit: 1536m + mem_reservation: 512m + cpus: 1.5 + # Readiness probe against the same endpoint the deploy workflow polls + # (/api/health/ready -> 200 {"ok":true} only when Mongo is connected AND the + # mediasoup workers are alive; 503 otherwise). This is dependency-aware, so a + # container that is up but can't actually serve calls is reported unhealthy. + # The runtime image is node:22-slim with no curl/wget, so use node's global + # fetch; host networking means the server is reachable on localhost:5000. + # start_period covers mediasoup worker spin-up. + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://localhost:5000/api/health/ready').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s environment: NODE_ENV: production AWS_REGION: ${AWS_REGION:?set AWS_REGION} diff --git a/e2e/package.json b/e2e/package.json index 99f4c6e..60d1281 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -4,6 +4,9 @@ "version": "0.0.0", "description": "A-Meet end-to-end test harness (Playwright)", "type": "module", + "engines": { + "node": ">=20.19.0" + }, "scripts": { "test": "playwright test", "test:sfu": "playwright test --config playwright.sfu.config.js", diff --git a/package.json b/package.json index 3f4c160..708d270 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "private": true, "description": "A-Meet — a Google Meet clone (MERN + Socket.io + mediasoup SFU)", + "engines": { + "node": ">=22.13.0" + }, "workspaces": [ "client", "server", diff --git a/server/.env.example b/server/.env.example index e9e7485..fa2e90f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -2,6 +2,14 @@ PORT=5000 NODE_ENV=development +# Pino log level (trace|debug|info|warn|error|fatal). Overrides everywhere when +# set; otherwise defaults to `debug` in development and `info` in production. +LOG_LEVEL=debug + +# AWS region. Local dev leaves this unset; docker-compose.prod.yml sets it in +# production, where it selects the CloudWatch logs and SSM parameter region. +# AWS_REGION=eu-north-1 + # Production only: docker-compose.prod.yml sets this to an SSM path and the # image entrypoint loads/decrypts its parameters before the app starts. # Local development leaves it unset and continues to use this .env file. diff --git a/server/package.json b/server/package.json index 4ea8d0e..7978d6f 100644 --- a/server/package.json +++ b/server/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "description": "A-Meet server — Express + Mongoose + Passport + Socket.io", "main": "index.js", + "engines": { + "node": ">=22.13.0" + }, "scripts": { "dev": "tsx watch src/server.ts", "start": "tsx src/server.ts", diff --git a/shared/package.json b/shared/package.json index 641729d..ee54fca 100644 --- a/shared/package.json +++ b/shared/package.json @@ -8,6 +8,9 @@ "exports": { ".": "./src/index.ts" }, + "engines": { + "node": ">=20.19.0" + }, "scripts": { "lint": "eslint .", "typecheck": "tsc -p tsconfig.json"