diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 09e406ad..38f55482 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -329,20 +329,89 @@ jobs: skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" shell: bash - - name: Copy tag from Docker Hub to GHCR - # Mirror the already-built image (all architectures) to GHCR so we can sign it + - name: Copy tags from Docker Hub to GHCR + # Mirror the already-built images (all architectures) to GHCR so we can sign them # Wait a bit for both architectures to be available in Docker Hub manifest env: REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json run: | set -euo pipefail TAG=${{ env.TAG }} - echo "Waiting for multi-arch manifest to be ready..." + MAJOR_TAG=$(echo $TAG | cut -d. -f1) + MINOR_TAG=$(echo $TAG | cut -d. -f1,2) + + echo "Waiting for multi-arch manifests to be ready..." sleep 30 - echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" - skopeo copy --all --retry-times 3 \ - docker://$DOCKERHUB_IMAGE:$TAG \ - docker://$GHCR_IMAGE:$TAG + + # Determine if this is an RC release + IS_RC="false" + if echo "$TAG" | grep -qE "rc[0-9]+$"; then + IS_RC="true" + fi + + if [ "$IS_RC" = "true" ]; then + echo "RC release detected - copying version-specific tags only" + + # SQLite OSS + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:$TAG \ + docker://$GHCR_IMAGE:$TAG + + # PostgreSQL OSS + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:postgresql-$TAG \ + docker://$GHCR_IMAGE:postgresql-$TAG + + # SQLite Enterprise + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:ee-$TAG \ + docker://$GHCR_IMAGE:ee-$TAG + + # PostgreSQL Enterprise + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:ee-postgresql-$TAG \ + docker://$GHCR_IMAGE:ee-postgresql-$TAG + else + echo "Regular release detected - copying all tags (latest, major, minor, full version)" + + # SQLite OSS - all tags + for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:${TAG_SUFFIX}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:$TAG_SUFFIX \ + docker://$GHCR_IMAGE:$TAG_SUFFIX + done + + # PostgreSQL OSS - all tags + for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG_SUFFIX}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:postgresql-$TAG_SUFFIX \ + docker://$GHCR_IMAGE:postgresql-$TAG_SUFFIX + done + + # SQLite Enterprise - all tags + for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-${TAG_SUFFIX}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:ee-$TAG_SUFFIX \ + docker://$GHCR_IMAGE:ee-$TAG_SUFFIX + done + + # PostgreSQL Enterprise - all tags + for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG_SUFFIX}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:ee-postgresql-$TAG_SUFFIX \ + docker://$GHCR_IMAGE:ee-postgresql-$TAG_SUFFIX + done + fi + + echo "All images copied successfully to GHCR!" shell: bash - name: Login to GitHub Container Registry (for cosign) @@ -371,28 +440,62 @@ jobs: issuer="https://token.actions.githubusercontent.com" id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs) - for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do - echo "Processing ${IMAGE}:${TAG}" + # Determine if this is an RC release + IS_RC="false" + if echo "$TAG" | grep -qE "rc[0-9]+$"; then + IS_RC="true" + fi - DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')" - REF="${IMAGE}@${DIGEST}" - echo "Resolved digest: ${REF}" + # Define image variants to sign + if [ "$IS_RC" = "true" ]; then + echo "RC release - signing version-specific tags only" + IMAGE_TAGS=( + "${TAG}" + "postgresql-${TAG}" + "ee-${TAG}" + "ee-postgresql-${TAG}" + ) + else + echo "Regular release - signing all tags" + MAJOR_TAG=$(echo $TAG | cut -d. -f1) + MINOR_TAG=$(echo $TAG | cut -d. -f1,2) + IMAGE_TAGS=( + "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG" + "postgresql-latest" "postgresql-$MAJOR_TAG" "postgresql-$MINOR_TAG" "postgresql-$TAG" + "ee-latest" "ee-$MAJOR_TAG" "ee-$MINOR_TAG" "ee-$TAG" + "ee-postgresql-latest" "ee-postgresql-$MAJOR_TAG" "ee-postgresql-$MINOR_TAG" "ee-postgresql-$TAG" + ) + fi - echo "==> cosign sign (keyless) --recursive ${REF}" - cosign sign --recursive "${REF}" + # Sign each image variant for both registries + for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do + for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do + echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}" - echo "==> cosign sign (key) --recursive ${REF}" - cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')" + REF="${BASE_IMAGE}@${DIGEST}" + echo "Resolved digest: ${REF}" - echo "==> cosign verify (public key) ${REF}" - cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text + echo "==> cosign sign (keyless) --recursive ${REF}" + cosign sign --recursive "${REF}" - echo "==> cosign verify (keyless policy) ${REF}" - cosign verify \ - --certificate-oidc-issuer "${issuer}" \ - --certificate-identity-regexp "${id_regex}" \ - "${REF}" -o text + echo "==> cosign sign (key) --recursive ${REF}" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + + echo "==> cosign verify (public key) ${REF}" + cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text + + echo "==> cosign verify (keyless policy) ${REF}" + cosign verify \ + --certificate-oidc-issuer "${issuer}" \ + --certificate-identity-regexp "${id_regex}" \ + "${REF}" -o text + + echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}" + done done + + echo "All images signed and verified successfully!" shell: bash post-run: diff --git a/.github/workflows/cicd.yml.backup b/.github/workflows/cicd.yml.backup new file mode 100644 index 00000000..09e406ad --- /dev/null +++ b/.github/workflows/cicd.yml.backup @@ -0,0 +1,426 @@ +name: CI/CD Pipeline + +# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries. +# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. + +permissions: + contents: read + packages: write # for GHCR push + id-token: write # for Cosign Keyless (OIDC) Signing + +# Required secrets: +# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub +# - GITHUB_TOKEN: used for GHCR login and OIDC keyless signing +# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +jobs: + pre-run: + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Start EC2 instances + run: | + aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} + echo "EC2 instances started" + + + release-arm: + name: Build and Release (ARM64) + runs-on: [self-hosted, linux, arm64, us-east-1] + needs: [pre-run] + if: >- + ${{ + needs.pre-run.result == 'success' + }} + # Job-level timeout to avoid runaway or stuck runs + timeout-minutes: 120 + env: + # Target images + DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Monitor storage space + run: | + THRESHOLD=75 + USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') + echo "Used space: $USED_SPACE%" + if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then + echo "Used space is below the threshold of 75% free. Running Docker system prune." + echo y | docker system prune -a + else + echo "Storage space is above the threshold. No action needed." + fi + + - name: Log in to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash + + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/consts.ts + shell: bash + + - name: Check if release candidate + id: check-rc + run: | + TAG=${{ env.TAG }} + if [[ "$TAG" == *"-rc."* ]]; then + echo "IS_RC=true" >> $GITHUB_ENV + else + echo "IS_RC=false" >> $GITHUB_ENV + fi + shell: bash + + - name: Build and push Docker images (Docker Hub - ARM64) + run: | + TAG=${{ env.TAG }} + if [ "$IS_RC" = "true" ]; then + make build-rc-arm tag=$TAG + else + make build-release-arm tag=$TAG + fi + echo "Built & pushed ARM64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" + shell: bash + + release-amd: + name: Build and Release (AMD64) + runs-on: [self-hosted, linux, x64, us-east-1] + needs: [pre-run] + if: >- + ${{ + needs.pre-run.result == 'success' + }} + # Job-level timeout to avoid runaway or stuck runs + timeout-minutes: 120 + env: + # Target images + DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Monitor storage space + run: | + THRESHOLD=75 + USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') + echo "Used space: $USED_SPACE%" + if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then + echo "Used space is below the threshold of 75% free. Running Docker system prune." + echo y | docker system prune -a + else + echo "Storage space is above the threshold. No action needed." + fi + + - name: Log in to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash + + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/consts.ts + shell: bash + + - name: Check if release candidate + id: check-rc + run: | + TAG=${{ env.TAG }} + if [[ "$TAG" == *"-rc."* ]]; then + echo "IS_RC=true" >> $GITHUB_ENV + else + echo "IS_RC=false" >> $GITHUB_ENV + fi + shell: bash + + - name: Build and push Docker images (Docker Hub - AMD64) + run: | + TAG=${{ env.TAG }} + if [ "$IS_RC" = "true" ]; then + make build-rc-amd tag=$TAG + else + make build-release-amd tag=$TAG + fi + echo "Built & pushed AMD64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" + shell: bash + + create-manifest: + name: Create Multi-Arch Manifests + runs-on: [self-hosted, linux, x64, us-east-1] + needs: [release-arm, release-amd] + if: >- + ${{ + needs.release-arm.result == 'success' && + needs.release-amd.result == 'success' + }} + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Log in to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash + + - name: Check if release candidate + id: check-rc + run: | + TAG=${{ env.TAG }} + if [[ "$TAG" == *"-rc."* ]]; then + echo "IS_RC=true" >> $GITHUB_ENV + else + echo "IS_RC=false" >> $GITHUB_ENV + fi + shell: bash + + - name: Create multi-arch manifests + run: | + TAG=${{ env.TAG }} + if [ "$IS_RC" = "true" ]; then + make create-manifests-rc tag=$TAG + else + make create-manifests tag=$TAG + fi + echo "Created multi-arch manifests for tag: ${TAG}" + shell: bash + + sign-and-package: + name: Sign and Package + runs-on: [self-hosted, linux, x64, us-east-1] + needs: [release-arm, release-amd, create-manifest] + if: >- + ${{ + needs.release-arm.result == 'success' && + needs.release-amd.result == 'success' && + needs.create-manifest.result == 'success' + }} + # Job-level timeout to avoid runaway or stuck runs + timeout-minutes: 120 + env: + # Target images + DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash + + - name: Install Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + with: + go-version: 1.24 + + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/consts.ts + shell: bash + + - name: Pull latest Gerbil version + id: get-gerbil-tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') + echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV + shell: bash + + - name: Pull latest Badger version + id: get-badger-tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') + echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV + shell: bash + + - name: Update install/main.go + run: | + PANGOLIN_VERSION=${{ env.TAG }} + GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} + BADGER_VERSION=${{ env.LATEST_BADGER_TAG }} + sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go + sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go + sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go + echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION" + cat install/main.go + shell: bash + + - name: Build installer + working-directory: install + run: | + make go-build-release + + - name: Upload artifacts from /install/bin + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: install-bin + path: install/bin/ + + - name: Install skopeo + jq + # skopeo: copy/inspect images between registries + # jq: JSON parsing tool used to extract digest values + run: | + sudo apt-get update -y + sudo apt-get install -y skopeo jq + skopeo --version + shell: bash + + - name: Login to GHCR + env: + REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json + run: | + mkdir -p "$(dirname "$REGISTRY_AUTH_FILE")" + skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" + shell: bash + + - name: Copy tag from Docker Hub to GHCR + # Mirror the already-built image (all architectures) to GHCR so we can sign it + # Wait a bit for both architectures to be available in Docker Hub manifest + env: + REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json + run: | + set -euo pipefail + TAG=${{ env.TAG }} + echo "Waiting for multi-arch manifest to be ready..." + sleep 30 + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:$TAG \ + docker://$GHCR_IMAGE:$TAG + shell: bash + + - name: Login to GitHub Container Registry (for cosign) + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install cosign + # cosign is used to sign and verify container images (key and keyless) + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Dual-sign and verify (GHCR & Docker Hub) + # Sign each image by digest using keyless (OIDC) and key-based signing, + # then verify both the public key signature and the keyless OIDC signature. + env: + TAG: ${{ env.TAG }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_YES: "true" + run: | + set -euo pipefail + + issuer="https://token.actions.githubusercontent.com" + id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs) + + for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do + echo "Processing ${IMAGE}:${TAG}" + + DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')" + REF="${IMAGE}@${DIGEST}" + echo "Resolved digest: ${REF}" + + echo "==> cosign sign (keyless) --recursive ${REF}" + cosign sign --recursive "${REF}" + + echo "==> cosign sign (key) --recursive ${REF}" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + + echo "==> cosign verify (public key) ${REF}" + cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text + + echo "==> cosign verify (keyless policy) ${REF}" + cosign verify \ + --certificate-oidc-issuer "${issuer}" \ + --certificate-identity-regexp "${id_regex}" \ + "${REF}" -o text + done + shell: bash + + post-run: + needs: [pre-run, release-arm, release-amd, create-manifest, sign-and-package] + if: >- + ${{ + always() && + needs.pre-run.result == 'success' && + (needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure') && + (needs.release-amd.result == 'success' || needs.release-amd.result == 'skipped' || needs.release-amd.result == 'failure') && + (needs.create-manifest.result == 'success' || needs.create-manifest.result == 'skipped' || needs.create-manifest.result == 'failure') && + (needs.sign-and-package.result == 'success' || needs.sign-and-package.result == 'skipped' || needs.sign-and-package.result == 'failure') + }} + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Stop EC2 instances + run: | + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} + echo "EC2 instances stopped" diff --git a/.github/workflows/saas.yml b/.github/workflows/saas.yml new file mode 100644 index 00000000..0c36de25 --- /dev/null +++ b/.github/workflows/saas.yml @@ -0,0 +1,125 @@ +name: CI/CD Pipeline + +# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries. +# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. + +permissions: + contents: read + packages: write # for GHCR push + id-token: write # for Cosign Keyless (OIDC) Signing + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+" + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +jobs: + pre-run: + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Start EC2 instances + run: | + aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + echo "EC2 instances started" + + + release-arm: + name: Build and Release (ARM64) + runs-on: [self-hosted, linux, arm64, us-east-1] + needs: [pre-run] + if: >- + ${{ + needs.pre-run.result == 'success' + }} + # Job-level timeout to avoid runaway or stuck runs + timeout-minutes: 120 + env: + # Target images + AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }} + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Monitor storage space + run: | + THRESHOLD=75 + USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') + echo "Used space: $USED_SPACE%" + if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then + echo "Used space is below the threshold of 75% free. Running Docker system prune." + echo y | docker system prune -a + else + echo "Storage space is above the threshold. No action needed." + fi + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash + + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/consts.ts + shell: bash + + - name: Build and push Docker images (Docker Hub - ARM64) + run: | + TAG=${{ env.TAG }} + make build-saas tag=$TAG + echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}" + shell: bash + + post-run: + needs: [pre-run, release-arm] + if: >- + ${{ + always() && + needs.pre-run.result == 'success' && + (needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure') + }} + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Stop EC2 instances + run: | + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + echo "EC2 instances stopped" diff --git a/Dockerfile b/Dockerfile index c59490b6..3d0a0f68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,20 @@ FROM node:24-alpine AS builder +# OCI Image Labels - Build Args for dynamic values +ARG VERSION="dev" +ARG REVISION="" +ARG CREATED="" +ARG LICENSE="AGPL-3.0" + WORKDIR /app ARG BUILD=oss ARG DATABASE=sqlite +# Derive title and description based on BUILD type +ARG IMAGE_TITLE="Pangolin" +ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" + RUN apk add --no-cache curl tzdata python3 make g++ # COPY package.json package-lock.json ./ @@ -69,4 +79,17 @@ RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs COPY server/db/names.json ./dist/names.json COPY public ./public +# OCI Image Labels +# https://github.com/opencontainers/image-spec/blob/main/annotations.md +LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \ + org.opencontainers.image.url="https://github.com/fosrl/pangolin" \ + org.opencontainers.image.documentation="https://docs.pangolin.net" \ + org.opencontainers.image.vendor="Fossorial" \ + org.opencontainers.image.licenses="${LICENSE}" \ + org.opencontainers.image.title="${IMAGE_TITLE}" \ + org.opencontainers.image.description="${IMAGE_DESCRIPTION}" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.created="${CREATED}" + CMD ["npm", "run", "start"] diff --git a/Makefile b/Makefile index ae31f50c..da31b6e2 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,25 @@ major_tag := $(shell echo $(tag) | cut -d. -f1) minor_tag := $(shell echo $(tag) | cut -d. -f1,2) +# OCI label variables +CREATED := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") + +# Common OCI build args for OSS builds +OCI_ARGS_OSS = --build-arg VERSION=$(tag) \ + --build-arg REVISION=$(REVISION) \ + --build-arg CREATED=$(CREATED) \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" + +# Common OCI build args for Enterprise builds +OCI_ARGS_EE = --build-arg VERSION=$(tag) \ + --build-arg REVISION=$(REVISION) \ + --build-arg CREATED=$(CREATED) \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" + .PHONY: build-release build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql build-release: build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql @@ -15,6 +34,7 @@ build-sqlite: docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ + $(OCI_ARGS_OSS) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:latest \ --tag fosrl/pangolin:$(major_tag) \ @@ -30,6 +50,7 @@ build-postgresql: docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ + $(OCI_ARGS_OSS) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:postgresql-latest \ --tag fosrl/pangolin:postgresql-$(major_tag) \ @@ -45,6 +66,7 @@ build-ee-sqlite: docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ + $(OCI_ARGS_EE) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-latest \ --tag fosrl/pangolin:ee-$(major_tag) \ @@ -60,6 +82,7 @@ build-ee-postgresql: docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ + $(OCI_ARGS_EE) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-latest \ --tag fosrl/pangolin:ee-postgresql-$(major_tag) \ @@ -67,6 +90,18 @@ build-ee-postgresql: --tag fosrl/pangolin:ee-postgresql-$(tag) \ --push . +build-saas: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release tag="; \ + exit 1; \ + fi + docker buildx build \ + --build-arg BUILD=saas \ + --build-arg DATABASE=pg \ + --platform linux/arm64 \ + --tag $(AWS_IMAGE):$(tag) \ + --push . + build-release-arm: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release-arm tag="; \ @@ -74,9 +109,16 @@ build-release-arm: fi @MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \ MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \ + CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:latest-arm64 \ --tag fosrl/pangolin:$$MAJOR_TAG-arm64 \ @@ -86,6 +128,11 @@ build-release-arm: docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:postgresql-latest-arm64 \ --tag fosrl/pangolin:postgresql-$$MAJOR_TAG-arm64 \ @@ -95,6 +142,12 @@ build-release-arm: docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:ee-latest-arm64 \ --tag fosrl/pangolin:ee-$$MAJOR_TAG-arm64 \ @@ -104,6 +157,12 @@ build-release-arm: docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:ee-postgresql-latest-arm64 \ --tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG-arm64 \ @@ -118,9 +177,16 @@ build-release-amd: fi @MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \ MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \ + CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:latest-amd64 \ --tag fosrl/pangolin:$$MAJOR_TAG-amd64 \ @@ -130,6 +196,11 @@ build-release-amd: docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:postgresql-latest-amd64 \ --tag fosrl/pangolin:postgresql-$$MAJOR_TAG-amd64 \ @@ -139,6 +210,12 @@ build-release-amd: docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:ee-latest-amd64 \ --tag fosrl/pangolin:ee-$$MAJOR_TAG-amd64 \ @@ -148,6 +225,12 @@ build-release-amd: docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-latest-amd64 \ --tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG-amd64 \ @@ -201,27 +284,51 @@ build-rc: echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:$(tag) \ - --push . + --push . && \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:postgresql-$(tag) \ - --push . + --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-$(tag) \ - --push . + --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-$(tag) \ --push . @@ -231,27 +338,51 @@ build-rc-arm: echo "Error: tag is required. Usage: make build-rc-arm tag="; \ exit 1; \ fi + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:$(tag)-arm64 \ --push . && \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:postgresql-$(tag)-arm64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:ee-$(tag)-arm64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:ee-postgresql-$(tag)-arm64 \ --push . @@ -261,27 +392,51 @@ build-rc-amd: echo "Error: tag is required. Usage: make build-rc-amd tag="; \ exit 1; \ fi + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:$(tag)-amd64 \ --push . && \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:postgresql-$(tag)-amd64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:ee-$(tag)-amd64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-$(tag)-amd64 \ --push . @@ -314,16 +469,52 @@ create-manifests-rc: echo "All RC multi-arch manifests created successfully!" build-arm: - docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest . + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker buildx build \ + --build-arg VERSION=dev \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + -t fosrl/pangolin:latest . build-x86: - docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker buildx build \ + --build-arg VERSION=dev \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + -t fosrl/pangolin:latest . dev-build-sqlite: - docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest . + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker build \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=dev \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + -t fosrl/pangolin:latest . dev-build-pg: - docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest . + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker build \ + --build-arg DATABASE=pg \ + --build-arg VERSION=dev \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + -t fosrl/pangolin:postgresql-latest . test: docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest diff --git a/config/traefik/dynamic_config.yml b/config/traefik/dynamic_config.yml index 8465a9cf..c098ee4f 100644 --- a/config/traefik/dynamic_config.yml +++ b/config/traefik/dynamic_config.yml @@ -1,5 +1,9 @@ http: middlewares: + badger: + plugin: + badger: + disableForwardAuth: true redirect-to-https: redirectScheme: scheme: https @@ -13,6 +17,7 @@ http: - web middlewares: - redirect-to-https + - badger # Next.js router (handles everything except API and WebSocket paths) next-router: @@ -21,6 +26,8 @@ http: priority: 10 entryPoints: - websecure + middlewares: + - badger tls: certResolver: letsencrypt @@ -31,6 +38,8 @@ http: priority: 100 entryPoints: - websecure + middlewares: + - badger tls: certResolver: letsencrypt diff --git a/install/config/traefik/traefik_config.yml b/install/config/traefik/traefik_config.yml index a9693ce6..0709b461 100644 --- a/install/config/traefik/traefik_config.yml +++ b/install/config/traefik/traefik_config.yml @@ -43,9 +43,12 @@ entryPoints: http: tls: certResolver: "letsencrypt" + encodedCharacters: + allowEncodedSlash: true + allowEncodedQuestionMark: true serversTransport: insecureSkipVerify: true ping: - entryPoint: "web" \ No newline at end of file + entryPoint: "web" diff --git a/install/main.go b/install/main.go index de001df2..a231da2d 100644 --- a/install/main.go +++ b/install/main.go @@ -340,7 +340,7 @@ func collectUserInput(reader *bufio.Reader) Config { // Basic configuration fmt.Println("\n=== Basic Configuration ===") - config.IsEnterprise = readBoolNoDefault(reader, "Do you want to install the Enterprise version of Pangolin? The EE is free for persoal use or for businesses making less than 100k USD annually.") + config.IsEnterprise = readBoolNoDefault(reader, "Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.") config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 877b2250..f85b6c9e 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Конфигуриране на достъп за организация", "idpUpdatedDescription": "Идентификационният доставчик беше актуализиран успешно", "redirectUrl": "URL за пренасочване", + "orgIdpRedirectUrls": "URL адреси за пренасочване", "redirectUrlAbout": "За URL за пренасочване", "redirectUrlAboutDescription": "Това е URL адресът, към който потребителите ще бъдат пренасочени след удостоверяване. Трябва да конфигурирате този URL адрес в настройките на доставчика на идентичност.", "pangolinAuth": "Authent - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Съгласен съм с", "termsOfService": "условията за ползване", "and": "и", - "privacyPolicy": "политиката за поверителност" + "privacyPolicy": "политика за поверителност." }, "signUpMarketing": { "keepMeInTheLoop": "Дръж ме в течение с новини, актуализации и нови функции чрез имейл." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Въведете потвърждение.", "blueprintViewDetails": "Подробности.", "defaultIdentityProvider": "По подразбиране доставчик на идентичност.", + "defaultIdentityProviderDescription": "Когато е избран основен доставчик на идентичност, потребителят ще бъде автоматично пренасочен към доставчика за удостоверяване.", "editInternalResourceDialogNetworkSettings": "Мрежови настройки.", "editInternalResourceDialogAccessPolicy": "Политика за достъп.", "editInternalResourceDialogAddRoles": "Добавяне на роли.", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 32629d71..50330652 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Konfigurace přístupu pro organizaci", "idpUpdatedDescription": "Poskytovatel identity byl úspěšně aktualizován", "redirectUrl": "Přesměrovat URL", + "orgIdpRedirectUrls": "Přesměrovat URL", "redirectUrlAbout": "O přesměrování URL", "redirectUrlAboutDescription": "Toto je URL, na kterou budou uživatelé po ověření přesměrováni. Tuto URL je třeba nastavit v nastavení poskytovatele identity.", "pangolinAuth": "Auth - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Souhlasím s", "termsOfService": "podmínky služby", "and": "a", - "privacyPolicy": "zásady ochrany osobních údajů" + "privacyPolicy": "zásady ochrany osobních údajů." }, "signUpMarketing": { "keepMeInTheLoop": "Udržujte mě ve smyčce s novinkami, aktualizacemi a novými funkcemi e-mailem." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Zadejte potvrzení", "blueprintViewDetails": "Detaily", "defaultIdentityProvider": "Výchozí poskytovatel identity", + "defaultIdentityProviderDescription": "Pokud je vybrán výchozí poskytovatel identity, uživatel bude automaticky přesměrován na poskytovatele pro ověření.", "editInternalResourceDialogNetworkSettings": "Nastavení sítě", "editInternalResourceDialogAccessPolicy": "Přístupová politika", "editInternalResourceDialogAddRoles": "Přidat role", diff --git a/messages/de-DE.json b/messages/de-DE.json index 4a25cc43..f9878faa 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Zugriff für eine Organisation konfigurieren", "idpUpdatedDescription": "Identitätsanbieter erfolgreich aktualisiert", "redirectUrl": "Weiterleitungs-URL", + "orgIdpRedirectUrls": "Umleitungs-URLs", "redirectUrlAbout": "Über die Weiterleitungs-URL", "redirectUrlAboutDescription": "Dies ist die URL, zu der Benutzer nach der Authentifizierung umgeleitet werden. Sie müssen diese URL in den Einstellungen des Identity Providers konfigurieren.", "pangolinAuth": "Authentifizierung - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Ich stimme den", "termsOfService": "Nutzungsbedingungen zu", "and": "und", - "privacyPolicy": "Datenschutzrichtlinie" + "privacyPolicy": "datenschutzrichtlinie." }, "signUpMarketing": { "keepMeInTheLoop": "Halten Sie mich auf dem Laufenden mit Neuigkeiten, Updates und neuen Funktionen per E-Mail." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Bestätigung eingeben", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Standard Identitätsanbieter", + "defaultIdentityProviderDescription": "Wenn ein Standard-Identity Provider ausgewählt ist, wird der Benutzer zur Authentifizierung automatisch an den Anbieter weitergeleitet.", "editInternalResourceDialogNetworkSettings": "Netzwerkeinstellungen", "editInternalResourceDialogAccessPolicy": "Zugriffsrichtlinie", "editInternalResourceDialogAddRoles": "Rollen hinzufügen", diff --git a/messages/en-US.json b/messages/en-US.json index 57821a6d..12e4f63f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1118,6 +1118,10 @@ "actionUpdateIdpOrg": "Update IDP Org", "actionCreateClient": "Create Client", "actionDeleteClient": "Delete Client", + "actionArchiveClient": "Archive Client", + "actionUnarchiveClient": "Unarchive Client", + "actionBlockClient": "Block Client", + "actionUnblockClient": "Unblock Client", "actionUpdateClient": "Update Client", "actionListClients": "List Clients", "actionGetClient": "Get Client", @@ -1135,7 +1139,7 @@ "create": "Create", "orgs": "Organizations", "loginError": "An error occurred while logging in", - "loginRequiredForDevice": "Login is required to authenticate your device.", + "loginRequiredForDevice": "Login is required for your device.", "passwordForgot": "Forgot your password?", "otpAuth": "Two-Factor Authentication", "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", @@ -1480,7 +1484,7 @@ "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", "and": "and", - "privacyPolicy": "privacy policy" + "privacyPolicy": "privacy policy." }, "signUpMarketing": { "keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email." @@ -1876,7 +1880,7 @@ "orgAuthChooseIdpDescription": "Choose your identity provider to continue", "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", "orgAuthSignInWithPangolin": "Sign in with Pangolin", - "orgAuthSignInToOrg": "Sign in to an organization", + "orgAuthSignInToOrg": "Use organization's identity provider", "orgAuthSelectOrgTitle": "Organization Sign In", "orgAuthSelectOrgDescription": "Enter your organization ID to continue", "orgAuthOrgIdPlaceholder": "your-organization", @@ -2244,7 +2248,7 @@ "deviceOrganizationsAccess": "Access to all organizations your account has access to", "deviceAuthorize": "Authorize {applicationName}", "deviceConnected": "Device Connected!", - "deviceAuthorizedMessage": "Device is authorized to access your account.", + "deviceAuthorizedMessage": "Device is authorized to access your account. Please return to the client application.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "View Devices", "viewDevicesDescription": "Manage your connected devices", @@ -2394,5 +2398,31 @@ "maintenanceScreenTitle": "Service Temporarily Unavailable", "maintenanceScreenMessage": "We are currently experiencing technical difficulties. Please check back soon.", "maintenanceScreenEstimatedCompletion": "Estimated Completion:", - "createInternalResourceDialogDestinationRequired": "Destination is required" + "createInternalResourceDialogDestinationRequired": "Destination is required", + "available": "Available", + "archived": "Archived", + "noArchivedDevices": "No archived devices found", + "deviceArchived": "Device archived", + "deviceArchivedDescription": "The device has been successfully archived.", + "errorArchivingDevice": "Error archiving device", + "failedToArchiveDevice": "Failed to archive device", + "deviceQuestionArchive": "Are you sure you want to archive this device?", + "deviceMessageArchive": "The device will be archived and removed from your active devices list.", + "deviceArchiveConfirm": "Archive Device", + "archiveDevice": "Archive Device", + "archive": "Archive", + "deviceUnarchived": "Device unarchived", + "deviceUnarchivedDescription": "The device has been successfully unarchived.", + "errorUnarchivingDevice": "Error unarchiving device", + "failedToUnarchiveDevice": "Failed to unarchive device", + "unarchive": "Unarchive", + "archiveClient": "Archive Client", + "archiveClientQuestion": "Are you sure you want to archive this client?", + "archiveClientMessage": "The client will be archived and removed from your active clients list.", + "archiveClientConfirm": "Archive Client", + "blockClient": "Block Client", + "blockClientQuestion": "Are you sure you want to block this client?", + "blockClientMessage": "The device will be forced to disconnect if currently connected. You can unblock the device later.", + "blockClientConfirm": "Block Client", + "active": "Active" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 00f2238e..78336315 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Configurar acceso para una organización", "idpUpdatedDescription": "Proveedor de identidad actualizado correctamente", "redirectUrl": "URL de redirección", + "orgIdpRedirectUrls": "Redirigir URL", "redirectUrlAbout": "Acerca de la URL de redirección", "redirectUrlAboutDescription": "Esta es la URL a la que los usuarios serán redireccionados después de la autenticación. Necesitas configurar esta URL en la configuración del proveedor de identidad.", "pangolinAuth": "Autenticación - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Estoy de acuerdo con los", "termsOfService": "términos del servicio", "and": "y", - "privacyPolicy": "política de privacidad" + "privacyPolicy": "política de privacidad." }, "signUpMarketing": { "keepMeInTheLoop": "Mantenerme en el bucle con noticias, actualizaciones y nuevas características por correo electrónico." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Ingresar confirmación", "blueprintViewDetails": "Detalles", "defaultIdentityProvider": "Proveedor de identidad predeterminado", + "defaultIdentityProviderDescription": "Cuando se selecciona un proveedor de identidad por defecto, el usuario será redirigido automáticamente al proveedor de autenticación.", "editInternalResourceDialogNetworkSettings": "Configuración de red", "editInternalResourceDialogAccessPolicy": "Política de acceso", "editInternalResourceDialogAddRoles": "Agregar roles", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 0bb4b398..2447dd93 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Configurer l'accès pour une organisation", "idpUpdatedDescription": "Fournisseur d'identité mis à jour avec succès", "redirectUrl": "URL de redirection", + "orgIdpRedirectUrls": "URL de redirection", "redirectUrlAbout": "À propos de l'URL de redirection", "redirectUrlAboutDescription": "C'est l'URL vers laquelle les utilisateurs seront redirigés après l'authentification. Vous devez configurer cette URL dans les paramètres du fournisseur d'identité.", "pangolinAuth": "Auth - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Je suis d'accord avec", "termsOfService": "les conditions d'utilisation", "and": "et", - "privacyPolicy": "la politique de confidentialité" + "privacyPolicy": "politique de confidentialité." }, "signUpMarketing": { "keepMeInTheLoop": "Gardez-moi dans la boucle avec des nouvelles, des mises à jour et de nouvelles fonctionnalités par courriel." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Entrez la confirmation", "blueprintViewDetails": "Détails", "defaultIdentityProvider": "Fournisseur d'identité par défaut", + "defaultIdentityProviderDescription": "Lorsqu'un fournisseur d'identité par défaut est sélectionné, l'utilisateur sera automatiquement redirigé vers le fournisseur pour authentification.", "editInternalResourceDialogNetworkSettings": "Paramètres réseau", "editInternalResourceDialogAccessPolicy": "Politique d'accès", "editInternalResourceDialogAddRoles": "Ajouter des rôles", diff --git a/messages/it-IT.json b/messages/it-IT.json index 48eccb2c..2ca14b0d 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Configura l'accesso per un'organizzazione", "idpUpdatedDescription": "Provider di identità aggiornato con successo", "redirectUrl": "URL di Reindirizzamento", + "orgIdpRedirectUrls": "Reindirizza URL", "redirectUrlAbout": "Informazioni sull'URL di Reindirizzamento", "redirectUrlAboutDescription": "Questo è l'URL a cui gli utenti saranno reindirizzati dopo l'autenticazione. È necessario configurare questo URL nelle impostazioni del provider di identità.", "pangolinAuth": "Autenticazione - Pangolina", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Accetto i", "termsOfService": "termini di servizio", "and": "e", - "privacyPolicy": "informativa sulla privacy" + "privacyPolicy": "informativa sulla privacy." }, "signUpMarketing": { "keepMeInTheLoop": "Tienimi in loop con notizie, aggiornamenti e nuove funzionalità via e-mail." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Inserisci conferma", "blueprintViewDetails": "Dettagli", "defaultIdentityProvider": "Provider di Identità Predefinito", + "defaultIdentityProviderDescription": "Quando viene selezionato un provider di identità predefinito, l'utente verrà automaticamente reindirizzato al provider per l'autenticazione.", "editInternalResourceDialogNetworkSettings": "Impostazioni di Rete", "editInternalResourceDialogAccessPolicy": "Politica di Accesso", "editInternalResourceDialogAddRoles": "Aggiungi Ruoli", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index c8f2ed50..83d8ae36 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "조직에 대한 접근을 구성하십시오.", "idpUpdatedDescription": "아이덴티티 제공자가 성공적으로 업데이트되었습니다", "redirectUrl": "리디렉션 URL", + "orgIdpRedirectUrls": "리디렉션 URL", "redirectUrlAbout": "리디렉션 URL에 대한 정보", "redirectUrlAboutDescription": "사용자가 인증 후 리디렉션될 URL입니다. 이 URL을 신원 제공자 설정에서 구성해야 합니다.", "pangolinAuth": "인증 - 판골린", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "동의합니다", "termsOfService": "서비스 약관", "and": "및", - "privacyPolicy": "개인 정보 보호 정책" + "privacyPolicy": "개인 정보 보호 정책." }, "signUpMarketing": { "keepMeInTheLoop": "이메일을 통해 소식, 업데이트 및 새로운 기능을 받아보세요." @@ -2349,6 +2350,7 @@ "enterConfirmation": "확인 입력", "blueprintViewDetails": "세부 정보", "defaultIdentityProvider": "기본 아이덴티티 공급자", + "defaultIdentityProviderDescription": "기본 ID 공급자가 선택되면, 사용자는 인증을 위해 자동으로 해당 공급자로 리디렉션됩니다.", "editInternalResourceDialogNetworkSettings": "네트워크 설정", "editInternalResourceDialogAccessPolicy": "액세스 정책", "editInternalResourceDialogAddRoles": "역할 추가", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 6fbe819c..94b51c65 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Konfigurer tilgang for en organisasjon", "idpUpdatedDescription": "Identitetsleverandør vellykket oppdatert", "redirectUrl": "Omdirigerings-URL", + "orgIdpRedirectUrls": "Omadressere URL'er", "redirectUrlAbout": "Om omdirigerings-URL", "redirectUrlAboutDescription": "Dette er URLen som brukere vil bli omdirigert etter autentisering. Du må konfigurere denne URLen i identitetsleverandørens innstillinger.", "pangolinAuth": "Autentisering - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Jeg godtar", "termsOfService": "brukervilkårene", "and": "og", - "privacyPolicy": "personvernerklæringen" + "privacyPolicy": "retningslinjer for personvern" }, "signUpMarketing": { "keepMeInTheLoop": "Hold meg i løken med nyheter, oppdateringer og nye funksjoner via e-post." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Skriv inn bekreftelse", "blueprintViewDetails": "Detaljer", "defaultIdentityProvider": "Standard identitetsleverandør", + "defaultIdentityProviderDescription": "Når en standard identitetsleverandør er valgt, vil brukeren automatisk bli omdirigert til leverandøren for autentisering.", "editInternalResourceDialogNetworkSettings": "Nettverksinnstillinger", "editInternalResourceDialogAccessPolicy": "Tilgangsregler for tilgang", "editInternalResourceDialogAddRoles": "Legg til roller", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index cf59e0b7..5057a53b 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Toegang voor een organisatie configureren", "idpUpdatedDescription": "Identity provider succesvol bijgewerkt", "redirectUrl": "Omleidings URL", + "orgIdpRedirectUrls": "URL's omleiden", "redirectUrlAbout": "Over omleidings-URL", "redirectUrlAboutDescription": "Dit is de URL waarnaar gebruikers worden doorverwezen na verificatie. U moet deze URL configureren in de instellingen van de identiteitsprovider.", "pangolinAuth": "Authenticatie - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Ik ga akkoord met de", "termsOfService": "servicevoorwaarden", "and": "en", - "privacyPolicy": "privacybeleid" + "privacyPolicy": "privacy beleid" }, "signUpMarketing": { "keepMeInTheLoop": "Houd me op de hoogte met nieuws, updates en nieuwe functies per e-mail." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Bevestiging invoeren", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Standaard Identiteitsprovider", + "defaultIdentityProviderDescription": "Wanneer een standaard identity provider is geselecteerd, zal de gebruiker automatisch worden doorgestuurd naar de provider voor authenticatie.", "editInternalResourceDialogNetworkSettings": "Netwerkinstellingen", "editInternalResourceDialogAccessPolicy": "Toegangsbeleid", "editInternalResourceDialogAddRoles": "Rollen toevoegen", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index e907ea55..e01d64ca 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Skonfiguruj dostęp dla organizacji", "idpUpdatedDescription": "Dostawca tożsamości został pomyślnie zaktualizowany", "redirectUrl": "URL przekierowania", + "orgIdpRedirectUrls": "Przekieruj adresy URL", "redirectUrlAbout": "O URL przekierowania", "redirectUrlAboutDescription": "Jest to adres URL, na który użytkownicy zostaną przekierowani po uwierzytelnieniu. Musisz skonfigurować ten adres URL w ustawieniach dostawcy tożsamości.", "pangolinAuth": "Autoryzacja - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Zgadzam się z", "termsOfService": "warunkami usługi", "and": "oraz", - "privacyPolicy": "polityką prywatności" + "privacyPolicy": "polityka prywatności." }, "signUpMarketing": { "keepMeInTheLoop": "Zachowaj mnie w pętli z wiadomościami, aktualizacjami i nowymi funkcjami przez e-mail." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Wprowadź potwierdzenie", "blueprintViewDetails": "Szczegóły", "defaultIdentityProvider": "Domyślny dostawca tożsamości", + "defaultIdentityProviderDescription": "Gdy zostanie wybrany domyślny dostawca tożsamości, użytkownik zostanie automatycznie przekierowany do dostawcy w celu uwierzytelnienia.", "editInternalResourceDialogNetworkSettings": "Ustawienia sieci", "editInternalResourceDialogAccessPolicy": "Polityka dostępowa", "editInternalResourceDialogAddRoles": "Dodaj role", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index f20191dd..2f4b5e79 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Configurar acesso para uma organização", "idpUpdatedDescription": "Provedor de identidade atualizado com sucesso", "redirectUrl": "URL de Redirecionamento", + "orgIdpRedirectUrls": "Redirecionar URLs", "redirectUrlAbout": "Sobre o URL de Redirecionamento", "redirectUrlAboutDescription": "Essa é a URL para a qual os usuários serão redirecionados após a autenticação. Você precisa configurar esta URL nas configurações do provedor de identidade.", "pangolinAuth": "Autenticação - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Concordo com", "termsOfService": "os termos de serviço", "and": "e", - "privacyPolicy": "política de privacidade" + "privacyPolicy": "política de privacidade." }, "signUpMarketing": { "keepMeInTheLoop": "Mantenha-me à disposição com notícias, atualizações e novos recursos por e-mail." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Inserir confirmação", "blueprintViewDetails": "Detalhes", "defaultIdentityProvider": "Provedor de Identidade Padrão", + "defaultIdentityProviderDescription": "Quando um provedor de identidade padrão for selecionado, o usuário será automaticamente redirecionado para o provedor de autenticação.", "editInternalResourceDialogNetworkSettings": "Configurações de Rede", "editInternalResourceDialogAccessPolicy": "Política de Acesso", "editInternalResourceDialogAddRoles": "Adicionar Funções", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 7ea25778..f93b599a 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Настроить доступ для организации", "idpUpdatedDescription": "Поставщик удостоверений успешно обновлён", "redirectUrl": "URL редиректа", + "orgIdpRedirectUrls": "Перенаправление URL", "redirectUrlAbout": "О редиректе URL", "redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках провайдера.", "pangolinAuth": "Аутентификация - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Я согласен с", "termsOfService": "условия использования", "and": "и", - "privacyPolicy": "политика конфиденциальности" + "privacyPolicy": "политика конфиденциальности." }, "signUpMarketing": { "keepMeInTheLoop": "Держите меня в цикле с новостями, обновлениями и новыми функциями по электронной почте." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Введите подтверждение", "blueprintViewDetails": "Подробности", "defaultIdentityProvider": "Поставщик удостоверений по умолчанию", + "defaultIdentityProviderDescription": "Когда выбран поставщик идентификации по умолчанию, пользователь будет автоматически перенаправлен на провайдер для аутентификации.", "editInternalResourceDialogNetworkSettings": "Настройки сети", "editInternalResourceDialogAccessPolicy": "Политика доступа", "editInternalResourceDialogAddRoles": "Добавить роли", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 0c923286..bbc6bbdf 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "Bir kuruluş için erişimi yapılandırın", "idpUpdatedDescription": "Kimlik sağlayıcı başarıyla güncellendi", "redirectUrl": "Yönlendirme URL'si", + "orgIdpRedirectUrls": "Yönlendirme URL'leri", "redirectUrlAbout": "Yönlendirme URL'si Hakkında", "redirectUrlAboutDescription": "Bu, kimlik doğrulamasından sonra kullanıcıların yönlendirileceği URL'dir. Bu URL'yi kimlik sağlayıcınızın ayarlarında yapılandırmanız gerekir.", "pangolinAuth": "Yetkilendirme - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "Kabul ediyorum", "termsOfService": "hizmet şartları", "and": "ve", - "privacyPolicy": "gizlilik politikası" + "privacyPolicy": "gizlilik politikası." }, "signUpMarketing": { "keepMeInTheLoop": "Bana e-posta yoluyla haberler, güncellemeler ve yeni özellikler hakkında bilgi verin." @@ -2349,6 +2350,7 @@ "enterConfirmation": "Onayı girin", "blueprintViewDetails": "Detaylar", "defaultIdentityProvider": "Varsayılan Kimlik Sağlayıcı", + "defaultIdentityProviderDescription": "Varsayılan bir kimlik sağlayıcı seçildiğinde, kullanıcı kimlik doğrulaması için otomatik olarak sağlayıcıya yönlendirilecektir.", "editInternalResourceDialogNetworkSettings": "Ağ Ayarları", "editInternalResourceDialogAccessPolicy": "Erişim Politikası", "editInternalResourceDialogAddRoles": "Roller Ekle", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 953a292e..6b487f8f 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -850,6 +850,7 @@ "orgPolicyConfig": "配置组织访问权限", "idpUpdatedDescription": "身份提供商更新成功", "redirectUrl": "重定向网址", + "orgIdpRedirectUrls": "重定向URL", "redirectUrlAbout": "关于重定向网址", "redirectUrlAboutDescription": "这是用户在验证后将被重定向到的URL。您需要在身份提供者的设置中配置此URL。", "pangolinAuth": "认证 - Pangolin", @@ -1479,7 +1480,7 @@ "IAgreeToThe": "我同意", "termsOfService": "服务条款", "and": "和", - "privacyPolicy": "隐私政策" + "privacyPolicy": "隐私政策。" }, "signUpMarketing": { "keepMeInTheLoop": "通过电子邮件让我在循环中保持新闻、更新和新功能。" @@ -2349,6 +2350,7 @@ "enterConfirmation": "输入确认", "blueprintViewDetails": "详细信息", "defaultIdentityProvider": "默认身份提供商", + "defaultIdentityProviderDescription": "当选择默认身份提供商时,用户将自动重定向到提供商进行身份验证。", "editInternalResourceDialogNetworkSettings": "网络设置", "editInternalResourceDialogAccessPolicy": "访问策略", "editInternalResourceDialogAddRoles": "添加角色", diff --git a/package.json b/package.json index af6a8dde..9d8c409b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", + "description": "Identity-aware VPN and proxy for remote access to anything, anywhere and Dashboard UI", "homepage": "https://github.com/fosrl/pangolin", "repository": { "type": "git", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 71017f8d..ea3ab6d1 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -78,6 +78,10 @@ export enum ActionsEnum { updateSiteResource = "updateSiteResource", createClient = "createClient", deleteClient = "deleteClient", + archiveClient = "archiveClient", + unarchiveClient = "unarchiveClient", + blockClient = "blockClient", + unblockClient = "unblockClient", updateClient = "updateClient", listClients = "listClients", getClient = "getClient", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index e49ed352..a0b1e3be 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -688,7 +688,9 @@ export const clients = pgTable("clients", { online: boolean("online").notNull().default(false), // endpoint: varchar("endpoint"), lastHolePunch: integer("lastHolePunch"), - maxConnections: integer("maxConnections") + maxConnections: integer("maxConnections"), + archived: boolean("archived").notNull().default(false), + blocked: boolean("blocked").notNull().default(false) }); export const clientSitesAssociationsCache = pgTable( @@ -726,7 +728,8 @@ export const olms = pgTable("olms", { userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" - }) + }), + archived: boolean("archived").notNull().default(false) }); export const olmSessions = pgTable("clientSession", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5f60b23e..84211a1e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -383,7 +383,9 @@ export const clients = sqliteTable("clients", { type: text("type").notNull(), // "olm" online: integer("online", { mode: "boolean" }).notNull().default(false), // endpoint: text("endpoint"), - lastHolePunch: integer("lastHolePunch") + lastHolePunch: integer("lastHolePunch"), + archived: integer("archived", { mode: "boolean" }).notNull().default(false), + blocked: integer("blocked", { mode: "boolean" }).notNull().default(false) }); export const clientSitesAssociationsCache = sqliteTable( @@ -423,7 +425,8 @@ export const olms = sqliteTable("olms", { userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" - }) + }), + archived: integer("archived", { mode: "boolean" }).notNull().default(false) }); export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 650d5b18..cba9bfa7 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -290,8 +290,8 @@ export const ClientResourceSchema = z alias: z .string() .regex( - /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, - "Alias must be a fully qualified domain name (e.g., example.com)" + /^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, + "Alias must be a fully qualified domain name with optional wildcards (e.g., example.com, *.example.com, host-0?.example.internal)" ) .optional(), roles: z diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 2e2e8ff0..56575191 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -13,3 +13,4 @@ export * from "./verifyApiKeyIsRoot"; export * from "./verifyApiKeyApiKeyAccess"; export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeySiteResourceAccess"; +export * from "./verifyApiKeyIdpAccess"; diff --git a/server/middlewares/integration/verifyApiKeyIdpAccess.ts b/server/middlewares/integration/verifyApiKeyIdpAccess.ts new file mode 100644 index 00000000..99b7e76b --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyIdpAccess.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { idp, idpOrg, apiKeyOrg } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyIdpAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const idpId = req.params.idpId || req.body.idpId || req.query.idpId; + const orgId = req.params.orgId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!idpId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID") + ); + } + + if (apiKey.isRoot) { + // Root keys can access any IDP in any org + return next(); + } + + const [idpRes] = await db + .select() + .from(idp) + .innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId)) + .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))) + .limit(1); + + if (!idpRes || !idpRes.idp || !idpRes.idpOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `IdP with ID ${idpId} not found for organization ${orgId}` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, idpRes.idpOrg.orgId) + ) + ); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying IDP access" + ) + ); + } +} diff --git a/server/private/lib/config.ts b/server/private/lib/config.ts index 97baf1e0..ae9ca5c7 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -139,6 +139,10 @@ export class PrivateConfig { process.env.USE_PANGOLIN_DNS = this.rawPrivateConfig.flags.use_pangolin_dns.toString(); } + if (this.rawPrivateConfig.flags.use_org_only_idp) { + process.env.USE_ORG_ONLY_IDP = + this.rawPrivateConfig.flags.use_org_only_idp.toString(); + } } public getRawPrivateConfig() { diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index 556fdcf7..97c89614 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -288,7 +288,7 @@ export function selectBestExitNode( const validNodes = pingResults.filter((n) => !n.error && n.weight > 0); if (validNodes.length === 0) { - logger.error("No valid exit nodes available"); + logger.debug("No valid exit nodes available"); return null; } diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts index 08496f65..7e68565e 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -24,7 +24,9 @@ export class LockManager { */ async acquireLock( lockKey: string, - ttlMs: number = 30000 + ttlMs: number = 30000, + maxRetries: number = 3, + retryDelayMs: number = 100 ): Promise { if (!redis || !redis.status || redis.status !== "ready") { return true; @@ -35,49 +37,67 @@ export class LockManager { }:${Date.now()}`; const redisKey = `lock:${lockKey}`; - try { - // Use SET with NX (only set if not exists) and PX (expire in milliseconds) - // This is atomic and handles both setting and expiration - const result = await redis.set( - redisKey, - lockValue, - "PX", - ttlMs, - "NX" - ); - - if (result === "OK") { - logger.debug( - `Lock acquired: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name - }` + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + // Use SET with NX (only set if not exists) and PX (expire in milliseconds) + // This is atomic and handles both setting and expiration + const result = await redis.set( + redisKey, + lockValue, + "PX", + ttlMs, + "NX" ); - return true; - } - // Check if the existing lock is from this worker (reentrant behavior) - const existingValue = await redis.get(redisKey); - if ( - existingValue && - existingValue.startsWith( - `${config.getRawConfig().gerbil.exit_node_name}:` - ) - ) { - // Extend the lock TTL since it's the same worker - await redis.pexpire(redisKey, ttlMs); - logger.debug( - `Lock extended: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name - }` - ); - return true; - } + if (result === "OK") { + logger.debug( + `Lock acquired: ${lockKey} by ${ + config.getRawConfig().gerbil.exit_node_name + }` + ); + return true; + } - return false; - } catch (error) { - logger.error(`Failed to acquire lock ${lockKey}:`, error); - return false; + // Check if the existing lock is from this worker (reentrant behavior) + const existingValue = await redis.get(redisKey); + if ( + existingValue && + existingValue.startsWith( + `${config.getRawConfig().gerbil.exit_node_name}:` + ) + ) { + // Extend the lock TTL since it's the same worker + await redis.pexpire(redisKey, ttlMs); + logger.debug( + `Lock extended: ${lockKey} by ${ + config.getRawConfig().gerbil.exit_node_name + }` + ); + return true; + } + + // If this isn't our last attempt, wait before retrying with exponential backoff + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + logger.debug( + `Lock ${lockKey} not available, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } catch (error) { + logger.error(`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, error); + // On error, still retry if we have attempts left + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } } + + logger.debug( + `Failed to acquire lock ${lockKey} after ${maxRetries} attempts` + ); + return false; } /** diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index c986e62d..374dee7c 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -83,7 +83,8 @@ export const privateConfigSchema = z.object({ flags: z .object({ enable_redis: z.boolean().optional().default(false), - use_pangolin_dns: z.boolean().optional().default(false) + use_pangolin_dns: z.boolean().optional().default(false), + use_org_only_idp: z.boolean().optional().default(false) }) .optional() .prefault({}), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 18410e62..f0343c5d 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -456,11 +456,11 @@ export async function getTraefikConfig( // ); } else if (resource.maintenanceModeType === "automatic") { showMaintenancePage = !hasHealthyServers; - if (showMaintenancePage) { - logger.warn( - `Resource ${resource.name} (${fullDomain}) has no healthy servers - showing maintenance page (AUTOMATIC mode)` - ); - } + // if (showMaintenancePage) { + // logger.warn( + // `Resource ${resource.name} (${fullDomain}) has no healthy servers - showing maintenance page (AUTOMATIC mode)` + // ); + // } } } diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 5249c026..8cda737e 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -27,7 +27,18 @@ export async function verifyValidSubscription( return next(); } - const tier = await getOrgTierData(req.params.orgId); + const orgId = req.params.orgId || req.body.orgId || req.query.orgId || req.userOrgId; + + if (!orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization ID is required to verify subscription" + ) + ); + } + + const tier = await getOrgTierData(orgId); if (!tier.active) { return next( diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index d9608e21..97c6db9f 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -436,18 +436,18 @@ authenticated.get( authenticated.post( "/re-key/:clientId/regenerate-client-secret", + verifyClientAccess, // this is first to set the org id verifyValidLicense, verifyValidSubscription, - verifyClientAccess, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateClientSecret ); authenticated.post( "/re-key/:siteId/regenerate-site-secret", + verifySiteAccess, // this is first to set the org id verifyValidLicense, verifyValidSubscription, - verifySiteAccess, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateSiteSecret ); diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index bbc0e0c8..a398dfe6 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -618,6 +618,16 @@ hybridRouter.get( ) .limit(1); + if (!result) { + return response(res, { + data: null, + success: true, + error: false, + message: "Login page not found", + status: HttpCode.OK + }); + } + if ( await checkExitNodeOrg( remoteExitNode.exitNodeId, @@ -633,16 +643,6 @@ hybridRouter.get( ); } - if (!result) { - return response(res, { - data: null, - success: true, - error: false, - message: "Login page not found", - status: HttpCode.OK - }); - } - return response(res, { data: result.loginPage, success: true, diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 9eefff8f..25861a54 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -18,7 +18,8 @@ import * as logs from "#private/routers/auditLogs"; import { verifyApiKeyHasAction, verifyApiKeyIsRoot, - verifyApiKeyOrgAccess + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess } from "@server/middlewares"; import { verifyValidSubscription, @@ -31,6 +32,8 @@ import { authenticated as a } from "@server/routers/integration"; import { logActionAudit } from "#private/middlewares"; +import config from "#private/lib/config"; +import { build } from "@server/build"; export const unauthenticated = ua; export const authenticated = a; @@ -88,3 +91,49 @@ authenticated.get( logActionAudit(ActionsEnum.exportLogs), logs.exportAccessAuditLogs ); + +authenticated.put( + "/org/:orgId/idp/oidc", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createIdp), + logActionAudit(ActionsEnum.createIdp), + orgIdp.createOrgOidcIdp +); + +authenticated.post( + "/org/:orgId/idp/:idpId/oidc", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess, + verifyApiKeyHasAction(ActionsEnum.updateIdp), + logActionAudit(ActionsEnum.updateIdp), + orgIdp.updateOrgOidcIdp +); + +authenticated.delete( + "/org/:orgId/idp/:idpId", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess, + verifyApiKeyHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.deleteOrgIdp +); + +authenticated.get( + "/org/:orgId/idp/:idpId", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess, + verifyApiKeyHasAction(ActionsEnum.getIdp), + orgIdp.getOrgIdp +); + +authenticated.get( + "/org/:orgId/idp", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listIdps), + orgIdp.listOrgIdps +); diff --git a/server/private/routers/loginPage/loadLoginPage.ts b/server/private/routers/loginPage/loadLoginPage.ts index 1b10e205..7a631c8a 100644 --- a/server/private/routers/loginPage/loadLoginPage.ts +++ b/server/private/routers/loginPage/loadLoginPage.ts @@ -40,6 +40,11 @@ async function query(orgId: string | undefined, fullDomain: string) { eq(loginPage.loginPageId, loginPageOrg.loginPageId) ) .limit(1); + + if (!res) { + return null; + } + return { ...res.loginPage, orgId: res.loginPageOrg.orgId @@ -65,6 +70,11 @@ async function query(orgId: string | undefined, fullDomain: string) { ) ) .limit(1); + + if (!res) { + return null; + } + return { ...res, orgId: orgLink.orgId diff --git a/server/private/routers/loginPage/loadLoginPageBranding.ts b/server/private/routers/loginPage/loadLoginPageBranding.ts index 823f75a6..1197bb10 100644 --- a/server/private/routers/loginPage/loadLoginPageBranding.ts +++ b/server/private/routers/loginPage/loadLoginPageBranding.ts @@ -48,6 +48,11 @@ async function query(orgId: string) { ) ) .limit(1); + + if (!res) { + return null; + } + return { ...res, orgId: orgLink.orgs.orgId, diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index f9f9d08c..4e2b666b 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -28,6 +28,7 @@ import { eq, InferInsertModel } from "drizzle-orm"; import { getOrgTierData } from "#private/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { build } from "@server/build"; +import config from "@server/private/lib/config"; const paramsSchema = z.strictObject({ orgId: z.string() @@ -94,8 +95,10 @@ export async function upsertLoginPageBranding( typeof loginPageBranding >; - if (build !== "saas") { - // org branding settings are only considered in the saas build + if ( + build !== "saas" && + !config.getRawPrivateConfig().flags.use_org_only_idp + ) { const { orgTitle, orgSubtitle, ...rest } = updateData; updateData = rest; } diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index 709f6167..36a5487e 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -46,22 +46,23 @@ const bodySchema = z.strictObject({ roleMapping: z.string().optional() }); -// registry.registerPath({ -// method: "put", -// path: "/idp/oidc", -// description: "Create an OIDC IdP.", -// tags: [OpenAPITags.Idp], -// request: { -// body: { -// content: { -// "application/json": { -// schema: bodySchema -// } -// } -// } -// }, -// responses: {} -// }); +registry.registerPath({ + method: "put", + path: "/org/{orgId}/idp/oidc", + description: "Create an OIDC IdP for a specific organization.", + tags: [OpenAPITags.Idp, OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); export async function createOrgOidcIdp( req: Request, diff --git a/server/private/routers/orgIdp/deleteOrgIdp.ts b/server/private/routers/orgIdp/deleteOrgIdp.ts index 721b91cb..176f4238 100644 --- a/server/private/routers/orgIdp/deleteOrgIdp.ts +++ b/server/private/routers/orgIdp/deleteOrgIdp.ts @@ -32,9 +32,9 @@ const paramsSchema = z registry.registerPath({ method: "delete", - path: "/idp/{idpId}", - description: "Delete IDP.", - tags: [OpenAPITags.Idp], + path: "/org/{orgId}/idp/{idpId}", + description: "Delete IDP for a specific organization.", + tags: [OpenAPITags.Idp, OpenAPITags.Org], request: { params: paramsSchema }, diff --git a/server/private/routers/orgIdp/getOrgIdp.ts b/server/private/routers/orgIdp/getOrgIdp.ts index 01ddc0f7..dd987c44 100644 --- a/server/private/routers/orgIdp/getOrgIdp.ts +++ b/server/private/routers/orgIdp/getOrgIdp.ts @@ -48,16 +48,16 @@ async function query(idpId: number, orgId: string) { return res; } -// registry.registerPath({ -// method: "get", -// path: "/idp/{idpId}", -// description: "Get an IDP by its IDP ID.", -// tags: [OpenAPITags.Idp], -// request: { -// params: paramsSchema -// }, -// responses: {} -// }); +registry.registerPath({ + method: "get", + path: "/org/:orgId/idp/:idpId", + description: "Get an IDP by its IDP ID for a specific organization.", + tags: [OpenAPITags.Idp, OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); export async function getOrgIdp( req: Request, diff --git a/server/private/routers/orgIdp/listOrgIdps.ts b/server/private/routers/orgIdp/listOrgIdps.ts index 36cbc627..61049c49 100644 --- a/server/private/routers/orgIdp/listOrgIdps.ts +++ b/server/private/routers/orgIdp/listOrgIdps.ts @@ -62,16 +62,17 @@ async function query(orgId: string, limit: number, offset: number) { return res; } -// registry.registerPath({ -// method: "get", -// path: "/idp", -// description: "List all IDP in the system.", -// tags: [OpenAPITags.Idp], -// request: { -// query: querySchema -// }, -// responses: {} -// }); +registry.registerPath({ + method: "get", + path: "/org/{orgId}/idp", + description: "List all IDP for a specific organization.", + tags: [OpenAPITags.Idp, OpenAPITags.Org], + request: { + query: querySchema, + params: paramsSchema + }, + responses: {} +}); export async function listOrgIdps( req: Request, diff --git a/server/private/routers/orgIdp/updateOrgOidcIdp.ts b/server/private/routers/orgIdp/updateOrgOidcIdp.ts index f29e4fc2..6474abda 100644 --- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts @@ -53,23 +53,23 @@ export type UpdateOrgIdpResponse = { idpId: number; }; -// registry.registerPath({ -// method: "post", -// path: "/idp/{idpId}/oidc", -// description: "Update an OIDC IdP.", -// tags: [OpenAPITags.Idp], -// request: { -// params: paramsSchema, -// body: { -// content: { -// "application/json": { -// schema: bodySchema -// } -// } -// } -// }, -// responses: {} -// }); +registry.registerPath({ + method: "post", + path: "/org/{orgId}/idp/{idpId}/oidc", + description: "Update an OIDC IdP for a specific organization.", + tags: [OpenAPITags.Idp, OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); export async function updateOrgOidcIdp( req: Request, diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 22040614..4600a4cc 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -16,4 +16,4 @@ export * from "./checkResourceSession"; export * from "./securityKey"; export * from "./startDeviceWebAuth"; export * from "./verifyDeviceWebAuth"; -export * from "./pollDeviceWebAuth"; +export * from "./pollDeviceWebAuth"; \ No newline at end of file diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index aade2f98..026ee4bb 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -49,27 +49,43 @@ const auditLogBuffer: Array<{ const BATCH_SIZE = 100; // Write to DB every 100 logs const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first +const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth let flushTimer: NodeJS.Timeout | null = null; +let isFlushInProgress = false; /** * Flush buffered logs to database */ async function flushAuditLogs() { - if (auditLogBuffer.length === 0) { + if (auditLogBuffer.length === 0 || isFlushInProgress) { return; } + isFlushInProgress = true; + // Take all current logs and clear buffer const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length); try { - // Batch insert all logs at once - await db.insert(requestAuditLog).values(logsToWrite); + // Batch insert logs in groups of 25 to avoid overwhelming the database + const BATCH_DB_SIZE = 25; + for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { + const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); + await db.insert(requestAuditLog).values(batch); + } logger.debug(`Flushed ${logsToWrite.length} audit logs to database`); } catch (error) { logger.error("Error flushing audit logs:", error); // On error, we lose these logs - consider a fallback strategy if needed // (e.g., write to file, or put back in buffer with retry limit) + } finally { + isFlushInProgress = false; + // If buffer filled up while we were flushing, flush again + if (auditLogBuffer.length >= BATCH_SIZE) { + flushAuditLogs().catch((err) => + logger.error("Error in follow-up flush:", err) + ); + } } } @@ -95,6 +111,10 @@ export async function shutdownAuditLogger() { clearTimeout(flushTimer); flushTimer = null; } + // Force flush even if one is in progress by waiting and retrying + while (isFlushInProgress) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } await flushAuditLogs(); } @@ -212,6 +232,14 @@ export async function logRequestAudit( ? stripPortFromHost(body.requestIp) : undefined; + // Prevent unbounded buffer growth - drop oldest entries if buffer is too large + if (auditLogBuffer.length >= MAX_BUFFER_SIZE) { + const dropped = auditLogBuffer.splice(0, BATCH_SIZE); + logger.warn( + `Audit log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries` + ); + } + // Add to buffer instead of writing directly to DB auditLogBuffer.push({ timestamp, diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 0da83c03..8dee788a 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1035,14 +1035,25 @@ export function isPathAllowed(pattern: string, path: string): boolean { logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`); logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`); + // Maximum recursion depth to prevent stack overflow and memory issues + const MAX_RECURSION_DEPTH = 100; + // Recursive function to try different wildcard matches - function matchSegments(patternIndex: number, pathIndex: number): boolean { - const indent = " ".repeat(pathIndex); // Indent based on recursion depth + function matchSegments(patternIndex: number, pathIndex: number, depth: number = 0): boolean { + // Check recursion depth limit + if (depth > MAX_RECURSION_DEPTH) { + logger.warn( + `Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"` + ); + return false; + } + + const indent = " ".repeat(depth); // Indent based on recursion depth const currentPatternPart = patternParts[patternIndex]; const currentPathPart = pathParts[pathIndex]; logger.debug( - `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})` + `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]` ); // If we've consumed all pattern parts, we should have consumed all path parts @@ -1075,7 +1086,7 @@ export function isPathAllowed(pattern: string, path: string): boolean { logger.debug( `${indent}Trying to skip wildcard (consume 0 segments)` ); - if (matchSegments(patternIndex + 1, pathIndex)) { + if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) { logger.debug( `${indent}Successfully matched by skipping wildcard` ); @@ -1086,7 +1097,7 @@ export function isPathAllowed(pattern: string, path: string): boolean { logger.debug( `${indent}Trying to consume segment "${currentPathPart}" for wildcard` ); - if (matchSegments(patternIndex, pathIndex + 1)) { + if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) { logger.debug( `${indent}Successfully matched by consuming segment for wildcard` ); @@ -1114,7 +1125,7 @@ export function isPathAllowed(pattern: string, path: string): boolean { logger.debug( `${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"` ); - return matchSegments(patternIndex + 1, pathIndex + 1); + return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1); } logger.debug( @@ -1135,10 +1146,10 @@ export function isPathAllowed(pattern: string, path: string): boolean { `${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"` ); // Move to next segments in both pattern and path - return matchSegments(patternIndex + 1, pathIndex + 1); + return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1); } - const result = matchSegments(0, 0); + const result = matchSegments(0, 0, 0); logger.debug(`Final result: ${result}`); return result; } diff --git a/server/routers/client/archiveClient.ts b/server/routers/client/archiveClient.ts new file mode 100644 index 00000000..330f6ed8 --- /dev/null +++ b/server/routers/client/archiveClient.ts @@ -0,0 +1,105 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { sendTerminateClient } from "./terminate"; + +const archiveClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/archive", + description: "Archive a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: archiveClientSchema + }, + responses: {} +}); + +export async function archiveClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = archiveClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (client.archived) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is already archived` + ) + ); + } + + await db.transaction(async (trx) => { + // Archive the client + await trx + .update(clients) + .set({ archived: true }) + .where(eq(clients.clientId, clientId)); + + // Rebuild associations to clean up related data + await rebuildClientAssociationsFromClient(client, trx); + + // Send terminate signal if there's an associated OLM + if (client.olmId) { + await sendTerminateClient(client.clientId, client.olmId); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client archived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to archive client" + ) + ); + } +} diff --git a/server/routers/client/blockClient.ts b/server/routers/client/blockClient.ts new file mode 100644 index 00000000..e1a00ff6 --- /dev/null +++ b/server/routers/client/blockClient.ts @@ -0,0 +1,101 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { sendTerminateClient } from "./terminate"; + +const blockClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/block", + description: "Block a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: blockClientSchema + }, + responses: {} +}); + +export async function blockClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = blockClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (client.blocked) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is already blocked` + ) + ); + } + + await db.transaction(async (trx) => { + // Block the client + await trx + .update(clients) + .set({ blocked: true }) + .where(eq(clients.clientId, clientId)); + + // Send terminate signal if there's an associated OLM and it's connected + if (client.olmId && client.online) { + await sendTerminateClient(client.clientId, client.olmId); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client blocked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to block client" + ) + ); + } +} diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index 775708ce..a16a2996 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -60,11 +60,12 @@ export async function deleteClient( ); } + // Only allow deletion of machine clients (clients without userId) if (client.userId) { return next( createHttpError( HttpCode.BAD_REQUEST, - `Cannot delete a user client with this endpoint` + `Cannot delete a user client. User clients must be archived instead.` ) ); } diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index cfb2652b..f054ce80 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -36,7 +36,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) { .select() .from(clients) .where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId))) - .leftJoin(olms, eq(olms.clientId, olms.clientId)) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) .limit(1); return res; } diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 8e88c11e..34614cc8 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -1,6 +1,10 @@ export * from "./pickClientDefaults"; export * from "./createClient"; export * from "./deleteClient"; +export * from "./archiveClient"; +export * from "./unarchiveClient"; +export * from "./blockClient"; +export * from "./unblockClient"; export * from "./listClients"; export * from "./updateClient"; export * from "./getClient"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 36e61c9d..18bc3e38 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -136,7 +136,10 @@ function queryClients( username: users.username, userEmail: users.email, niceId: clients.niceId, - agent: olms.agent + agent: olms.agent, + olmArchived: olms.archived, + archived: clients.archived, + blocked: clients.blocked }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) diff --git a/server/routers/client/unarchiveClient.ts b/server/routers/client/unarchiveClient.ts new file mode 100644 index 00000000..62c5c17c --- /dev/null +++ b/server/routers/client/unarchiveClient.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const unarchiveClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/unarchive", + description: "Unarchive a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: unarchiveClientSchema + }, + responses: {} +}); + +export async function unarchiveClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = unarchiveClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (!client.archived) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is not archived` + ) + ); + } + + // Unarchive the client + await db + .update(clients) + .set({ archived: false }) + .where(eq(clients.clientId, clientId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client unarchived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to unarchive client" + ) + ); + } +} diff --git a/server/routers/client/unblockClient.ts b/server/routers/client/unblockClient.ts new file mode 100644 index 00000000..82b608a2 --- /dev/null +++ b/server/routers/client/unblockClient.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const unblockClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/unblock", + description: "Unblock a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: unblockClientSchema + }, + responses: {} +}); + +export async function unblockClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = unblockClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (!client.blocked) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is not blocked` + ) + ); + } + + // Unblock the client + await db + .update(clients) + .set({ blocked: false }) + .where(eq(clients.clientId, clientId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client unblocked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to unblock client" + ) + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index cb5328ab..9b6490a5 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -174,6 +174,38 @@ authenticated.delete( client.deleteClient ); +authenticated.post( + "/client/:clientId/archive", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.archiveClient), + logActionAudit(ActionsEnum.archiveClient), + client.archiveClient +); + +authenticated.post( + "/client/:clientId/unarchive", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.unarchiveClient), + logActionAudit(ActionsEnum.unarchiveClient), + client.unarchiveClient +); + +authenticated.post( + "/client/:clientId/block", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.blockClient), + logActionAudit(ActionsEnum.blockClient), + client.blockClient +); + +authenticated.post( + "/client/:clientId/unblock", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.unblockClient), + logActionAudit(ActionsEnum.unblockClient), + client.unblockClient +); + authenticated.post( "/client/:clientId", verifyClientAccess, // this will check if the user has access to the client @@ -808,11 +840,18 @@ authenticated.put("/user/:userId/olm", verifyIsLoggedInUser, olm.createUserOlm); authenticated.get("/user/:userId/olms", verifyIsLoggedInUser, olm.listUserOlms); -authenticated.delete( - "/user/:userId/olm/:olmId", +authenticated.post( + "/user/:userId/olm/:olmId/archive", verifyIsLoggedInUser, verifyOlmAccess, - olm.deleteUserOlm + olm.archiveUserOlm +); + +authenticated.post( + "/user/:userId/olm/:olmId/unarchive", + verifyIsLoggedInUser, + verifyOlmAccess, + olm.unarchiveUserOlm ); authenticated.get( diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 4250458a..3373285b 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -751,9 +751,10 @@ authenticated.post( ); authenticated.get( - "/idp", - verifyApiKeyIsRoot, - verifyApiKeyHasAction(ActionsEnum.listIdps), + "/idp", // no guards on this because anyone can list idps for login purposes + // we do the same for the external api + // verifyApiKeyIsRoot, + // verifyApiKeyHasAction(ActionsEnum.listIdps), idp.listIdps ); @@ -842,6 +843,38 @@ authenticated.delete( client.deleteClient ); +authenticated.post( + "/client/:clientId/archive", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.archiveClient), + logActionAudit(ActionsEnum.archiveClient), + client.archiveClient +); + +authenticated.post( + "/client/:clientId/unarchive", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.unarchiveClient), + logActionAudit(ActionsEnum.unarchiveClient), + client.unarchiveClient +); + +authenticated.post( + "/client/:clientId/block", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.blockClient), + logActionAudit(ActionsEnum.blockClient), + client.blockClient +); + +authenticated.post( + "/client/:clientId/unblock", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.unblockClient), + logActionAudit(ActionsEnum.unblockClient), + client.unblockClient +); + authenticated.post( "/client/:clientId", verifyApiKeyClientAccess, diff --git a/server/routers/newt/handleReceiveBandwidthMessage.ts b/server/routers/newt/handleReceiveBandwidthMessage.ts index 3d060a0c..eb930e68 100644 --- a/server/routers/newt/handleReceiveBandwidthMessage.ts +++ b/server/routers/newt/handleReceiveBandwidthMessage.ts @@ -1,7 +1,7 @@ import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, Newt } from "@server/db"; -import { eq } from "drizzle-orm"; +import { clients } from "@server/db"; +import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; interface PeerBandwidth { @@ -10,13 +10,57 @@ interface PeerBandwidth { bytesOut: number; } +// Retry configuration for deadlock handling +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 50; + +/** + * Check if an error is a deadlock error + */ +function isDeadlockError(error: any): boolean { + return ( + error?.code === "40P01" || + error?.cause?.code === "40P01" || + (error?.message && error.message.includes("deadlock")) + ); +} + +/** + * Execute a function with retry logic for deadlock handling + */ +async function withDeadlockRetry( + operation: () => Promise, + context: string +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error: any) { + if (isDeadlockError(error) && attempt < MAX_RETRIES) { + attempt++; + const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; + const jitter = Math.random() * baseDelay; + const delay = baseDelay + jitter; + logger.warn( + `Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw error; + } + } +} + export const handleReceiveBandwidthMessage: MessageHandler = async ( context ) => { - const { message, client, sendToClient } = context; + const { message } = context; if (!message.data.bandwidthData) { logger.warn("No bandwidth data provided"); + return; } const bandwidthData: PeerBandwidth[] = message.data.bandwidthData; @@ -25,30 +69,40 @@ export const handleReceiveBandwidthMessage: MessageHandler = async ( throw new Error("Invalid bandwidth data"); } - await db.transaction(async (trx) => { - for (const peer of bandwidthData) { - const { publicKey, bytesIn, bytesOut } = peer; + // Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances + // This is critical for preventing deadlocks when multiple instances update the same clients + const sortedBandwidthData = [...bandwidthData].sort((a, b) => + a.publicKey.localeCompare(b.publicKey) + ); - // Find the client by public key - const [client] = await trx - .select() - .from(clients) - .where(eq(clients.pubKey, publicKey)) - .limit(1); + const currentTime = new Date().toISOString(); - if (!client) { - continue; - } + // Update each client individually with retry logic + // This reduces transaction scope and allows retries per-client + for (const peer of sortedBandwidthData) { + const { publicKey, bytesIn, bytesOut } = peer; - // Update the client's bandwidth usage - await trx - .update(clients) - .set({ - megabytesOut: (client.megabytesIn || 0) + bytesIn, - megabytesIn: (client.megabytesOut || 0) + bytesOut, - lastBandwidthUpdate: new Date().toISOString() - }) - .where(eq(clients.clientId, client.clientId)); + try { + await withDeadlockRetry(async () => { + // Use atomic SQL increment to avoid SELECT then UPDATE pattern + // This eliminates the need to read the current value first + await db + .update(clients) + .set({ + // Note: bytesIn from peer goes to megabytesOut (data sent to client) + // and bytesOut from peer goes to megabytesIn (data received from client) + megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`, + megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`, + lastBandwidthUpdate: currentTime + }) + .where(eq(clients.pubKey, publicKey)); + }, `update client bandwidth ${publicKey}`); + } catch (error) { + logger.error( + `Failed to update bandwidth for client ${publicKey}:`, + error + ); + // Continue with other clients even if one fails } - }); + } }; diff --git a/server/routers/olm/archiveUserOlm.ts b/server/routers/olm/archiveUserOlm.ts new file mode 100644 index 00000000..46abd1a1 --- /dev/null +++ b/server/routers/olm/archiveUserOlm.ts @@ -0,0 +1,81 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms, clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { sendTerminateClient } from "../client/terminate"; + +const paramsSchema = z + .object({ + userId: z.string(), + olmId: z.string() + }) + .strict(); + +export async function archiveUserOlm( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId } = parsedParams.data; + + // Archive the OLM and disconnect associated clients in a transaction + await db.transaction(async (trx) => { + // Find all clients associated with this OLM + const associatedClients = await trx + .select() + .from(clients) + .where(eq(clients.olmId, olmId)); + + // Disconnect clients from the OLM (set olmId to null) + for (const client of associatedClients) { + await trx + .update(clients) + .set({ olmId: null }) + .where(eq(clients.clientId, client.clientId)); + + await rebuildClientAssociationsFromClient(client, trx); + await sendTerminateClient(client.clientId, olmId); + } + + // Archive the OLM (set archived to true) + await trx + .update(olms) + .set({ archived: true }) + .where(eq(olms.olmId, olmId)); + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Device archived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to archive device" + ) + ); + } +} diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 6cfb5216..543a9f7e 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -1,7 +1,7 @@ import { db } from "@server/db"; import { disconnectClient } from "#dynamic/routers/ws"; import { getClientConfigVersion, MessageHandler } from "@server/routers/ws"; -import { clients, Olm } from "@server/db"; +import { clients, olms, Olm } from "@server/db"; import { eq, lt, isNull, and, or } from "drizzle-orm"; import logger from "@server/logger"; import { validateSessionToken } from "@server/auth/sessions/app"; @@ -109,29 +109,17 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { return; } - if (olm.userId) { - // we need to check a user token to make sure its still valid - const { session: userSession, user } = - await validateSessionToken(userToken); - if (!userSession || !user) { - logger.warn("Invalid user session for olm ping"); - return; // by returning here we just ignore the ping and the setInterval will force it to disconnect - } - if (user.userId !== olm.userId) { - logger.warn("User ID mismatch for olm ping"); - return; - } + if (!olm.clientId) { + logger.warn("Olm has no client ID!"); + return; + } + try { // get the client const [client] = await db .select() .from(clients) - .where( - and( - eq(clients.olmId, olm.olmId), - eq(clients.userId, olm.userId) - ) - ) + .where(eq(clients.clientId, olm.clientId)) .limit(1); if (!client) { @@ -139,39 +127,48 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { return; } - const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(userToken)) - ); - - const policyCheck = await checkOrgAccessPolicy({ - orgId: client.orgId, - userId: olm.userId, - sessionId // this is the user token passed in the message - }); - - if (!policyCheck.allowed) { - logger.warn( - `Olm user ${olm.userId} does not pass access policies for org ${client.orgId}: ${policyCheck.error}` + if (client.blocked) { + // NOTE: by returning we dont update the lastPing, so the offline checker will eventually disconnect them + logger.debug( + `Blocked client ${client.clientId} attempted olm ping` ); return; } - } - if (!olm.clientId) { - logger.warn("Olm has no client ID!"); - return; - } + if (olm.userId) { + // we need to check a user token to make sure its still valid + const { session: userSession, user } = + await validateSessionToken(userToken); + if (!userSession || !user) { + logger.warn("Invalid user session for olm ping"); + return; // by returning here we just ignore the ping and the setInterval will force it to disconnect + } + if (user.userId !== olm.userId) { + logger.warn("User ID mismatch for olm ping"); + return; + } + if (user.userId !== client.userId) { + logger.warn("Client user ID mismatch for olm ping"); + return; + } - try { - // Update the client's last ping timestamp - const [client] = await db - .update(clients) - .set({ - lastPing: Math.floor(Date.now() / 1000), - online: true - }) - .where(eq(clients.clientId, olm.clientId)) - .returning(); + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(userToken)) + ); + + const policyCheck = await checkOrgAccessPolicy({ + orgId: client.orgId, + userId: olm.userId, + sessionId // this is the user token passed in the message + }); + + if (!policyCheck.allowed) { + logger.warn( + `Olm user ${olm.userId} does not pass access policies for org ${client.orgId}: ${policyCheck.error}` + ); + return; + } + } // get the version const configVersion = await getClientConfigVersion(olm.olmId); @@ -182,6 +179,23 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { ); await sendOlmSyncMessage(olm, client); } + + // Update the client's last ping timestamp + await db + .update(clients) + .set({ + lastPing: Math.floor(Date.now() / 1000), + online: true, + archived: false + }) + .where(eq(clients.clientId, olm.clientId)); + + if (olm.archived) { + await db + .update(olms) + .set({ archived: false }) + .where(eq(olms.olmId, olm.olmId)); + } } catch (error) { logger.error("Error handling ping message", { error }); } diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 04eaa415..5c7d0a75 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -57,6 +57,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } + if (client.blocked) { + logger.debug(`Client ${client.clientId} is blocked. Ignoring register.`); + return; + } + const [org] = await db .select() .from(orgs) @@ -114,18 +119,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if ( (olmVersion && olm.version !== olmVersion) || - (olmAgent && olm.agent !== olmAgent) + (olmAgent && olm.agent !== olmAgent) || + olm.archived ) { await db .update(olms) .set({ version: olmVersion, - agent: olmAgent + agent: olmAgent, + archived: false }) .where(eq(olms.olmId, olm.olmId)); } - if (client.pubKey !== publicKey) { + if (client.pubKey !== publicKey || client.archived) { logger.info( "Public key mismatch. Updating public key and clearing session info..." ); @@ -133,7 +140,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { await db .update(clients) .set({ - pubKey: publicKey + pubKey: publicKey, + archived: false, }) .where(eq(clients.clientId, client.clientId)); diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 594ef9cb..6957c18b 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -3,9 +3,9 @@ export * from "./getOlmToken"; export * from "./createUserOlm"; export * from "./handleOlmRelayMessage"; export * from "./handleOlmPingMessage"; -export * from "./deleteUserOlm"; +export * from "./archiveUserOlm"; +export * from "./unarchiveUserOlm"; export * from "./listUserOlms"; -export * from "./deleteUserOlm"; export * from "./getUserOlm"; export * from "./handleOlmServerPeerAddMessage"; export * from "./handleOlmUnRelayMessage"; diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts index 2756c917..16585e9f 100644 --- a/server/routers/olm/listUserOlms.ts +++ b/server/routers/olm/listUserOlms.ts @@ -51,6 +51,7 @@ export type ListUserOlmsResponse = { name: string | null; clientId: number | null; userId: string | null; + archived: boolean; }>; pagination: { total: number; @@ -89,7 +90,7 @@ export async function listUserOlms( const { userId } = parsedParams.data; - // Get total count + // Get total count (including archived OLMs) const [totalCountResult] = await db .select({ count: count() }) .from(olms) @@ -97,7 +98,7 @@ export async function listUserOlms( const total = totalCountResult?.count || 0; - // Get OLMs for the current user + // Get OLMs for the current user (including archived OLMs) const userOlms = await db .select({ olmId: olms.olmId, @@ -105,7 +106,8 @@ export async function listUserOlms( version: olms.version, name: olms.name, clientId: olms.clientId, - userId: olms.userId + userId: olms.userId, + archived: olms.archived }) .from(olms) .where(eq(olms.userId, userId)) diff --git a/server/routers/olm/unarchiveUserOlm.ts b/server/routers/olm/unarchiveUserOlm.ts new file mode 100644 index 00000000..28d540b8 --- /dev/null +++ b/server/routers/olm/unarchiveUserOlm.ts @@ -0,0 +1,84 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; + +const paramsSchema = z + .object({ + userId: z.string(), + olmId: z.string() + }) + .strict(); + +export async function unarchiveUserOlm( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId } = parsedParams.data; + + // Check if OLM exists and is archived + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)) + .limit(1); + + if (!olm) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `OLM with ID ${olmId} not found` + ) + ); + } + + if (!olm.archived) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `OLM with ID ${olmId} is not archived` + ) + ); + } + + // Unarchive the OLM (set archived to false) + await db + .update(olms) + .set({ archived: false }) + .where(eq(olms.olmId, olmId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Device unarchived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to unarchive device" + ) + ); + } +} diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index b00340ee..c5321e98 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -213,9 +213,11 @@ export async function updateTarget( // When health check is disabled, reset hcHealth to "unknown" // to prevent previously unhealthy targets from being excluded + // Also when the site is not a newt, set hcHealth to "unknown" const hcHealthValue = parsedBody.data.hcEnabled === false || - parsedBody.data.hcEnabled === null + parsedBody.data.hcEnabled === null || + site.type !== "newt" ? "unknown" : undefined; diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index 786c8635..f6260073 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -285,7 +285,7 @@ export default function Page() { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 060f18ac..44d85b99 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -11,6 +11,7 @@ import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; import { Layout } from "@app/components/Layout"; import { adminNavSections } from "../navigation"; +import { pullEnv } from "@app/lib/pullEnv"; export const dynamic = "force-dynamic"; @@ -27,6 +28,8 @@ export default async function AdminLayout(props: LayoutProps) { const getUser = cache(verifySession); const user = await getUser(); + const env = pullEnv(); + if (!user || !user.serverAdmin) { redirect(`/`); } @@ -48,7 +51,7 @@ export default async function AdminLayout(props: LayoutProps) { return ( - + {props.children} diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 6a72006b..fae271f5 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -44,7 +44,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { return (
-
+
@@ -127,26 +127,6 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { )} - - - {t("docs")} - - - - {t("github")} -
)} diff --git a/src/app/auth/login/device/success/page.tsx b/src/app/auth/login/device/success/page.tsx index f725a867..6ee49587 100644 --- a/src/app/auth/login/device/success/page.tsx +++ b/src/app/auth/login/device/success/page.tsx @@ -7,6 +7,7 @@ import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { CheckCircle2 } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; +import { useEffect } from "react"; export default function DeviceAuthSuccessPage() { const { env } = useEnvContext(); @@ -20,6 +21,32 @@ export default function DeviceAuthSuccessPage() { ? env.branding.logo?.authPage?.height || 58 : 58; + useEffect(() => { + // Detect if we're on iOS or Android + const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; + const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; + const isAndroid = /android/i.test(userAgent); + + if (isAndroid) { + // For Android Chrome Custom Tabs, use intent:// scheme which works more reliably + // This explicitly tells Chrome to send an intent to the app, which will bring + // SignInCodeActivity back to the foreground (it has launchMode="singleTop") + setTimeout(() => { + window.location.href = "intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end"; + }, 500); + } else if (isIOS) { + // Wait 500ms then attempt to open the app + setTimeout(() => { + // Try to open the app using deep link + window.location.href = "pangolin://"; + + setTimeout(() => { + window.location.href = "https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS"; + }, 2000); + }, 500); + } + }, []); + return ( <> @@ -55,4 +82,4 @@ export default function DeviceAuthSuccessPage() {

); -} +} \ No newline at end of file diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index bd6327fd..0c9faafc 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -70,7 +70,7 @@ export default async function Page(props: { } let loginIdps: LoginFormIDP[] = []; - if (build !== "saas") { + if (build === "oss" || !env.flags.useOrgOnlyIdp) { const idpsRes = await cache( async () => await priv.get>("/idp") )(); @@ -103,6 +103,10 @@ export default async function Page(props: { redirect={redirectUrl} idps={loginIdps} forceLogin={forceLogin} + showOrgLogin={ + !isInvite && (build === "saas" || env.flags.useOrgOnlyIdp) + } + searchParams={searchParams} /> {(!signUpDisabled || isInvite) && ( @@ -120,35 +124,6 @@ export default async function Page(props: {

)} - - {!isInvite && build === "saas" ? ( -
- {t("needToSignInToOrg")} - - {t("orgAuthSignInToOrg")} - -
- ) : null} ); } - -function buildQueryString(searchParams: { - [key: string]: string | string[] | undefined; -}): string { - const params = new URLSearchParams(); - const redirect = searchParams.redirect; - const forceLogin = searchParams.forceLogin; - - if (redirect && typeof redirect === "string") { - params.set("redirect", redirect); - } - if (forceLogin && typeof forceLogin === "string") { - params.set("forceLogin", forceLogin); - } - const queryString = params.toString(); - return queryString ? `?${queryString}` : ""; -} diff --git a/src/app/auth/org/[orgId]/page.tsx b/src/app/auth/org/[orgId]/page.tsx index 1958a388..73e5f39b 100644 --- a/src/app/auth/org/[orgId]/page.tsx +++ b/src/app/auth/org/[orgId]/page.tsx @@ -11,6 +11,7 @@ import { } from "@server/routers/loginPage/types"; import { redirect } from "next/navigation"; import OrgLoginPage from "@app/components/OrgLoginPage"; +import { pullEnv } from "@app/lib/pullEnv"; export const dynamic = "force-dynamic"; @@ -21,7 +22,9 @@ export default async function OrgAuthPage(props: { const searchParams = await props.searchParams; const params = await props.params; - if (build !== "saas") { + const env = pullEnv(); + + if (build !== "saas" && !env.flags.useOrgOnlyIdp) { const queryString = new URLSearchParams(searchParams as any).toString(); redirect(`/auth/login${queryString ? `?${queryString}` : ""}`); } @@ -50,29 +53,25 @@ export default async function OrgAuthPage(props: { } catch (e) {} let loginIdps: LoginFormIDP[] = []; - if (build === "saas") { - const idpsRes = await priv.get>( - `/org/${orgId}/idp` - ); + const idpsRes = await priv.get>( + `/org/${orgId}/idp` + ); - loginIdps = idpsRes.data.data.idps.map((idp) => ({ - idpId: idp.idpId, - name: idp.name, - variant: idp.variant - })) as LoginFormIDP[]; - } + loginIdps = idpsRes.data.data.idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name, + variant: idp.variant + })) as LoginFormIDP[]; let branding: LoadLoginPageBrandingResponse | null = null; - if (build === "saas") { - try { - const res = await priv.get< - AxiosResponse - >(`/login-page-branding?orgId=${orgId}`); - if (res.status === 200) { - branding = res.data.data; - } - } catch (error) {} - } + try { + const res = await priv.get< + AxiosResponse + >(`/login-page-branding?orgId=${orgId}`); + if (res.status === 200) { + branding = res.data.data; + } + } catch (error) {} return ( diff --git a/src/app/globals.css b/src/app/globals.css index 70c614c0..731e1bff 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -178,4 +178,16 @@ p { .animate-dot-pulse { animation: dot-pulse 1.4s ease-in-out infinite; } + + /* Use JavaScript-set viewport height for mobile to handle keyboard properly */ + .h-screen-safe { + height: 100vh; /* Default for desktop and fallback */ + } + + /* Only apply custom viewport height on mobile */ + @media (max-width: 767px) { + .h-screen-safe { + height: var(--vh, 100vh); /* Use CSS variable set by ViewportHeightFix on mobile */ + } + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e76a5d2f..203dd778 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -22,6 +22,7 @@ import { TopLoader } from "@app/components/Toploader"; import Script from "next/script"; import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; import { TailwindIndicator } from "@app/components/TailwindIndicator"; +import { ViewportHeightFix } from "@app/components/ViewportHeightFix"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -77,7 +78,7 @@ export default async function RootLayout({ return ( - + {build === "saas" && (