diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..506a9a5 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,178 @@ +name: Build, Push, and Deploy + +on: + push: + branches: ["main"] + workflow_dispatch: + +env: + REGISTRY: ${{ secrets.REGISTRY }} + IMAGE_REPOSITORY: ${{ secrets.IMAGE_REPOSITORY }} + +jobs: + test: + name: Run unit and build checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "yarn" + + - name: Install frontend dependencies + run: yarn install --frozen-lockfile --non-interactive + + - name: Build frontend + run: yarn build + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.19" + + - name: Run Go tests + working-directory: signalling + run: go test ./... + + build-and-push: + name: Build and push container image + runs-on: ubuntu-latest + needs: test + permissions: + contents: read + packages: write + outputs: + image_uri: ${{ steps.image-metadata.outputs.uri }} + image_latest: ${{ steps.image-metadata.outputs.latest }} + steps: + - uses: actions/checkout@v4 + + - name: Derive image metadata + id: image-metadata + run: | + if [ -z "${REGISTRY}" ]; then + echo "REGISTRY secret must be provided" >&2 + exit 1 + fi + if [ -z "${IMAGE_REPOSITORY}" ]; then + echo "IMAGE_REPOSITORY secret must be provided" >&2 + exit 1 + fi + IMAGE_URI="${REGISTRY}/${IMAGE_REPOSITORY}:${GITHUB_SHA}" + IMAGE_LATEST="${REGISTRY}/${IMAGE_REPOSITORY}:latest" + echo "uri=${IMAGE_URI}" >> "$GITHUB_OUTPUT" + echo "latest=${IMAGE_LATEST}" >> "$GITHUB_OUTPUT" + echo "IMAGE_URI=${IMAGE_URI}" >> "$GITHUB_ENV" + echo "IMAGE_LATEST=${IMAGE_LATEST}" >> "$GITHUB_ENV" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.IMAGE_URI }} + ${{ env.IMAGE_LATEST }} + + deploy: + name: Deploy to VM + runs-on: ubuntu-latest + needs: build-and-push + if: ${{ github.ref == 'refs/heads/main' }} + steps: + - name: Prepare deployment directory + uses: appleboy/ssh-action@v0.1.10 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR=${{ secrets.DEPLOY_DIR }} + if [ -z "$DEPLOY_DIR" ]; then + echo "DEPLOY_DIR secret must be provided" >&2 + exit 1 + fi + mkdir -p "$DEPLOY_DIR" + + - name: Copy docker-compose file + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + source: deploy/docker-compose.yml + target: ${{ secrets.DEPLOY_DIR }} + + - name: Copy environment template when missing + uses: appleboy/ssh-action@v0.1.10 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + set -euo pipefail + DEPLOY_DIR=${{ secrets.DEPLOY_DIR }} + if [ -z "$DEPLOY_DIR" ]; then + echo "DEPLOY_DIR secret must be provided" >&2 + exit 1 + fi + if [ ! -f "$DEPLOY_DIR/.env" ]; then + cat <<'ENV' > "$DEPLOY_DIR/.env" +# Environment overrides for Kabootar deployment +CONTAINER_NAME=kabootar +WEB_PORT=80 +SIGNALLING_PORT=5000 +TURN_PORT=18937 +CORS_ENDPOINT=* +TURN_REALM=localhost +TURN_LISTEN_IP=0.0.0.0 +PUBLIC_IP=0.0.0.0 +SERVER_NAME=_ +ENV + fi + + - name: Deploy updated container + uses: appleboy/ssh-action@v0.1.10 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + set -euo pipefail + if [ -z "${{ secrets.REGISTRY_USERNAME }}" ] || [ -z "${{ secrets.REGISTRY_PASSWORD }}" ]; then + echo "Registry credentials are required for deployment" >&2 + exit 1 + fi + DEPLOY_DIR=${{ secrets.DEPLOY_DIR }} + if [ -z "$DEPLOY_DIR" ]; then + echo "DEPLOY_DIR secret must be provided" >&2 + exit 1 + fi + IMAGE_URI=${{ needs.build-and-push.outputs.image_uri }} + IMAGE_LATEST=${{ needs.build-and-push.outputs.image_latest }} + + mkdir -p "$DEPLOY_DIR" + docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_PASSWORD }} + docker pull "$IMAGE_URI" + docker tag "$IMAGE_URI" "$IMAGE_LATEST" + + cd "$DEPLOY_DIR" + export IMAGE="$IMAGE_URI" + docker compose pull kabootar + docker compose up -d --remove-orphans + docker image prune -f diff --git a/Dockerfile b/Dockerfile index bb8ec77..1949649 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,44 +1,46 @@ -# Use the official Golang image to create a build artifact. -FROM golang:1.18 as builder +# Build the signalling server binary +FROM golang:1.18 AS signalling-builder WORKDIR /app -# Copy the Go Modules manifests COPY signalling/go.mod signalling/go.sum ./ -# Download the dependencies RUN go mod download -# Copy the source code from the signalling directory -COPY signalling/ . - -# Build the application +COPY signalling/ ./ RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/signalling -# Use Nginx image -FROM nginx:alpine +# Build the frontend assets +FROM node:18-alpine AS frontend-builder -# Remove the default Nginx configuration file -RUN rm /etc/nginx/conf.d/default.conf +WORKDIR /app -# Copy a new configuration file from your project -COPY nginx.conf /etc/nginx/conf.d/nginx.conf.template +RUN npm install -g yarn@1.22.22 + +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --non-interactive -RUN apk --no-cache add ca-certificates +COPY tsconfig.json vite.config.ts tailwind.config.js postcss.config.js ./ +COPY index.html manifest.json ./ +COPY public ./public +COPY src ./src -WORKDIR /root/ +RUN yarn build -# Copy the pre-built binary file from the previous stage -COPY --from=builder /app/main /usr/local/bin/main +# Final runtime image with Nginx and the signalling server +FROM nginx:alpine + +RUN rm /etc/nginx/conf.d/default.conf \ + && apk --no-cache add ca-certificates gettext -# Copy the start.sh script -COPY signalling/start.sh . +WORKDIR /app + +COPY --from=signalling-builder /app/main /usr/local/bin/main +COPY --from=frontend-builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/nginx.conf.template +COPY signalling/start.sh ./start.sh -# Ensure start.sh is executable -RUN chmod +x start.sh +RUN chmod +x ./start.sh -# Expose the port the app runs on EXPOSE 80 443 18937 -# Use the start script as the entry point -# cat the generated start.sh script to see the contents -CMD ["/bin/sh", "./start.sh"] \ No newline at end of file +CMD ["/bin/sh", "./start.sh"] diff --git a/README.md b/README.md index 7fa18e1..8a3b9ca 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,40 @@ Inside the `signalling` directory: 1. Run `yarn install --frozen-lockfile` 1. Run `yarn dev` +## Automated builds and deployments + +The repository ships with a GitHub Actions workflow in +`.github/workflows/deploy.yml` that builds, publishes and deploys the combined +Nginx/signalling container whenever changes land on `main`. + +### Required GitHub secrets + +Add the following secrets under **Settings → Secrets and variables → Actions**: + +| Secret | Description | +| ------------------------------------------- | --------------------------------------------------------------- | +| `REGISTRY` | Registry host (for example `ghcr.io` or `docker.io`). | +| `IMAGE_REPOSITORY` | Repository path inside the registry (`owner/kabootar`). | +| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | Credentials used to push and pull the image. | +| `SSH_HOST` / `SSH_USER` / `SSH_PRIVATE_KEY` | SSH connection details for the target VM. | +| `DEPLOY_DIR` | Absolute path on the VM where the compose file and `.env` live. | + +If you store the image in GitHub Container Registry you can create a fine-grained +PAT with `packages:write` scope and use it for the registry credentials. + +### Preparing the VM + +1. Install Docker Engine and the Docker Compose v2 plugin. +2. Create the directory referenced by `DEPLOY_DIR` (for example `/opt/kabootar`). +3. Copy `deploy/.env.example` to `.env` inside that directory and update the + values with the public hostname, TURN settings and any TURN credentials. + GitHub Actions will manage the `IMAGE` entry automatically. +4. Ensure the deployment user can run `docker` commands without sudo. + +The workflow copies `deploy/docker-compose.yml` to the VM, pulls the freshly +published image and runs `docker compose up -d --remove-orphans`, so the +signalling server and frontend are served together via the Nginx container. + ## People - Akshit Garg diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..2220d9c --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,19 @@ +# Copy this file to kabootar.env and adjust values for your deployment. +# Secrets (TURN credentials, etc.) should be added after copying rather than +# committed to the repository. + +# The GitHub Actions workflow populates IMAGE automatically during deployments. +# When running manually, set IMAGE to a built container reference +# (e.g. ghcr.io/owner/kabootar:latest). +IMAGE= + +# Optional overrides for docker-compose. +CONTAINER_NAME=kabootar +WEB_PORT=80 +SIGNALLING_PORT=5000 +TURN_PORT=18937 +CORS_ENDPOINT=* +TURN_REALM=localhost +TURN_LISTEN_IP=0.0.0.0 +PUBLIC_IP=0.0.0.0 +SERVER_NAME=_ diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..6823247 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,19 @@ +version: "3.8" + +services: + kabootar: + image: ${IMAGE:?Set IMAGE to the published Kabootar image} + container_name: ${CONTAINER_NAME:-kabootar} + restart: unless-stopped + ports: + - "${WEB_PORT:-80}:80" + - "${TURN_PORT:-18937}:${TURN_PORT:-18937}/udp" + environment: + LISTEN_ADDRESS: 0.0.0.0 + PORT: ${SIGNALLING_PORT:-5000} + CORS_ENDPOINT: ${CORS_ENDPOINT:-*} + TURN_REALM: ${TURN_REALM:-localhost} + TURN_LISTEN_IP: ${TURN_LISTEN_IP:-0.0.0.0} + PUBLIC_IP: ${PUBLIC_IP:-0.0.0.0} + TURN_PORT: ${TURN_PORT:-18937} + SERVER_NAME: ${SERVER_NAME:-_} diff --git a/nginx.conf b/nginx.conf index 0425f2e..5292c80 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,14 +1,53 @@ server { listen 80; + server_name ${SERVER_NAME}; - server_name _; + root /usr/share/nginx/html; + index index.html; - location / { - proxy_pass http://localhost:$PORT ; # Ensure this matches the port your Go app listens on + location /ws/ { + proxy_pass http://127.0.0.1:${PORT}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header Host $host; - proxy_read_timeout 10800; # 3 hours connection timeout + 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; + proxy_read_timeout 10800; + } + + location /discover { + proxy_pass http://127.0.0.1:${PORT}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + 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; + proxy_read_timeout 10800; + } + + location /room { + proxy_pass http://127.0.0.1:${PORT}; + proxy_http_version 1.1; + proxy_set_header Host $host; + 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; + } + + location /ping { + proxy_pass http://127.0.0.1:${PORT}; + proxy_http_version 1.1; + proxy_set_header Host $host; + 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; + } + + location / { + try_files $uri $uri/ /index.html; } -} \ No newline at end of file +} diff --git a/signalling/start.sh b/signalling/start.sh index a60c438..cd62f4f 100644 --- a/signalling/start.sh +++ b/signalling/start.sh @@ -1,15 +1,16 @@ -#!/bin/bash - -# Environment variables or default values -LISTEN_ADDRESS=${LISTEN_ADDRESS:-"0.0.0.0"} -PORT=${PORT:-5000} -CORS_ENDPOINT=${CORS_ENDPOINT:-"http://localhost:3000"} -TURN_REALM=${TURN_REALM:-"localhost:3000"} -TURN_LISTEN_IP=${TURN_LISTEN_IP:-"0.0.0.0"} -PUBLIC_IP=${PUBLIC_IP:-"0.0.0.0"} -TURN_PORT=${TURN_PORT:-18937} - -# Generate kabootar.toml +#!/bin/sh + +set -eu + +LISTEN_ADDRESS="${LISTEN_ADDRESS:-0.0.0.0}" +PORT="${PORT:-5000}" +CORS_ENDPOINT="${CORS_ENDPOINT:-*}" +TURN_REALM="${TURN_REALM:-localhost}" +TURN_LISTEN_IP="${TURN_LISTEN_IP:-0.0.0.0}" +PUBLIC_IP="${PUBLIC_IP:-0.0.0.0}" +TURN_PORT="${TURN_PORT:-18937}" +SERVER_NAME="${SERVER_NAME:-_}" + cat < kabootar.toml listen_address = "${LISTEN_ADDRESS}:${PORT}" cors_endpoint = "${CORS_ENDPOINT}" @@ -19,13 +20,18 @@ public_ip = "${PUBLIC_IP}" turn_port = ${TURN_PORT} EOF -# Start the Go application -# Substitute environment variables in the Nginx config template and output to the final config file -envsubst '$PORT' < /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.conf - -# Start the Go application in the background +envsubst '$PORT $SERVER_NAME' < /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.conf main & +MAIN_PID=$! + +cleanup() { + if [ -n "${MAIN_PID:-}" ] && kill -0 "$MAIN_PID" 2>/dev/null; then + kill "$MAIN_PID" 2>/dev/null || true + wait "$MAIN_PID" 2>/dev/null || true + fi +} + +trap cleanup INT TERM EXIT -# Start Nginx in the foreground -nginx -g 'daemon off;' \ No newline at end of file +nginx -g 'daemon off;' diff --git a/src/config.ts b/src/config.ts index ff091ac..3d80f13 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,30 @@ export const iceServers: RTCConfiguration = { iceCandidatePoolSize: 10, }; -export const secure = true; -export const baseURL = "signaling.kabootar.meghrathod.dev"; +const envSecure = import.meta.env.VITE_SIGNALING_SECURE; +const envHost = import.meta.env.VITE_SIGNALING_HOST; +const isBrowser = typeof window !== "undefined"; + +let resolvedSecure = true; +if (envSecure !== undefined) { + resolvedSecure = envSecure === "true"; +} else if (import.meta.env.DEV) { + resolvedSecure = false; +} else if (isBrowser) { + resolvedSecure = window.location.protocol === "https:"; +} + +export const secure = resolvedSecure; + +let resolvedHost = "localhost"; +if (envHost !== undefined) { + resolvedHost = envHost; +} else if (import.meta.env.DEV) { + resolvedHost = "localhost:5000"; +} else if (isBrowser) { + resolvedHost = window.location.host; +} + +export const baseURL = resolvedHost; export const httpScheme = secure ? "https://" : "http://"; export const wsScheme = secure ? "wss://" : "ws://";