diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 034113495f..c17f985182 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -18,40 +18,43 @@ env: jobs: build-docker: runs-on: - - self-hosted - - Linux - - ${{ matrix.runner }} + - codebuild-defguard-core-runner-${{ github.run_id }}-${{ github.run_attempt }} + image:${{ matrix.os }} + instance-size:${{ matrix.size }} + strategy: matrix: - # cpu: [arm64, amd64, arm/v7] cpu: [arm64, amd64] include: - - cpu: arm64 - runner: ARM64 + - os: arm-3.0 + size: xlarge + cpu: arm64 tag: arm64 - - cpu: amd64 - runner: X64 + - os: ubuntu-7.0 + size: xlarge + cpu: amd64 tag: amd64 - # - cpu: arm/v7 - # runner: ARM - # tag: armv7 + + permissions: + contents: read + packages: write + steps: - name: Checkout uses: actions/checkout@v4 with: submodules: recursive + - name: Login to GitHub container registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - with: - buildkitd-config-inline: | - [registry."docker.io"] - mirrors = ["dockerhub-proxy.teonite.net"] + - name: Build container uses: docker/build-push-action@v6 with: @@ -60,13 +63,35 @@ jobs: provenance: false push: true tags: "${{ env.GHCR_REPO }}:${{ github.sha }}-${{ matrix.tag }}" - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: | + type=registry,ref=${{ env.GHCR_REPO }}:cache-${{ matrix.tag }} + type=registry,ref=${{ env.GHCR_REPO }}:cache-${{ matrix.tag }}-${{ github.ref_name }} + cache-to: type=registry,mode=max,ref=${{ env.GHCR_REPO }}:cache-${{ matrix.tag }}-${{ github.ref_name }} + + - name: Scan image with Trivy + uses: aquasecurity/trivy-action@0.32.0 + with: + image-ref: "${{ env.GHCR_REPO }}:${{ github.sha }}-${{ matrix.tag }}" + format: "table" + exit-code: "1" + ignore-unfixed: true + vuln-type: "os,library" + severity: "CRITICAL,HIGH,MEDIUM" docker-manifest: runs-on: [self-hosted, Linux] + + permissions: + contents: read + packages: write + id-token: write # needed for signing the images with GitHub OIDC Token + needs: [build-docker] + steps: + - name: Install Cosign + uses: sigstore/cosign-installer@v3.9.2 + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -75,12 +100,14 @@ jobs: ${{ env.GHCR_REPO }} flavor: ${{ inputs.flavor }} tags: ${{ inputs.tags }} + - name: Login to GitHub container registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Create and push manifests run: | tags='${{ env.GHCR_REPO }}:${{ github.sha }} ${{ steps.meta.outputs.tags }}' @@ -90,4 +117,13 @@ jobs: docker manifest create ${tag} ${{ env.GHCR_REPO }}:${{ github.sha }}-amd64 ${{ env.GHCR_REPO }}:${{ github.sha }}-arm64 docker manifest push ${tag} done - # ${{ env.GHCR_REPO }}:${{ github.sha }}-armv7 + + - name: Sign the images with GitHub OIDC Token + run: | + images='${{ env.GHCR_REPO }}:${{ github.sha }} ${{ steps.meta.outputs.tags }}' + cosign sign --yes ${images} + + - name: Verify image signatures + run: | + images='${{ env.GHCR_REPO }}:${{ github.sha }} ${{ steps.meta.outputs.tags }}' + cosign verify ${images} --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp="https://github.com/DefGuard/defguard" -o text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3257ad7498..14a78a70f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,12 +20,14 @@ on: jobs: test: - runs-on: [self-hosted, Linux, X64] - container: rust:1 + runs-on: + - codebuild-defguard-core-runner-${{ github.run_id }}-${{ github.run_attempt }} + + container: public.ecr.aws/docker/library/rust:1 services: postgres: - image: postgres:17-alpine + image: public.ecr.aws/docker/library/postgres:17-alpine env: POSTGRES_DB: defguard POSTGRES_USER: defguard @@ -52,21 +54,30 @@ jobs: uses: actions/checkout@v4 with: submodules: recursive + - name: Cache uses: Swatinem/rust-cache@v2 + - name: Install protoc run: apt-get update && apt-get -y install protobuf-compiler + - name: Check format run: | rustup component add rustfmt cargo fmt -- --check + - name: Run clippy linter run: | rustup component add clippy cargo clippy --all-targets --all-features -- -D warnings + - name: Run cargo deny - uses: EmbarkStudios/cargo-deny-action@v2 + run: | + cargo install cargo-deny + cargo deny check + - name: Install nextest uses: taiki-e/install-action@nextest + - name: Run tests run: cargo nextest run --locked --no-fail-fast diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index 490f5e6aaa..62bb823050 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -1,6 +1,8 @@ name: Build current image permissions: contents: read + id-token: write + packages: write on: push: branches: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4926315903..e81577820b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -10,21 +10,22 @@ permissions: jobs: test: - runs-on: [self-hosted, Linux, X64] + runs-on: + - codebuild-defguard-core-runner-${{ github.run_id }}-${{ github.run_attempt }} + instance-size:2xlarge + steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - with: - buildkitd-config-inline: | - [registry."docker.io"] - mirrors = ["dockerhub-proxy.teonite.net"] + - name: Login to GitHub container registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Export image tag run: | # strip "refs/heads.” to get just the branch name @@ -38,16 +39,19 @@ jobs: fi echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV echo "E2E tests will run on IMAGE_TAG=$IMAGE_TAG" + - name: Set up Node uses: actions/setup-node@v4 with: node-version-file: "./e2e/.nvmrc" + - name: Install pnpm id: pnpm-install uses: pnpm/action-setup@v4 with: version: 10 run_install: false + - name: Get pnpm store directory id: pnpm-cache shell: bash @@ -61,22 +65,26 @@ jobs: key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- + - name: Pull images run: docker compose --file './docker-compose.e2e.yaml' pull - - name: Start compose - run: docker compose --file './docker-compose.e2e.yaml' up -d + - name: Install E2E dependencies working-directory: ./e2e run: pnpm install --frozen-lockfile + - name: Install playwright chromium working-directory: ./e2e run: npx playwright install chromium + - name: run tests working-directory: ./e2e run: pnpm test + - name: Stop compose if: always() run: docker compose --file './docker-compose.e2e.yaml' down + - uses: actions/upload-artifact@v4 if: failure() with: @@ -84,11 +92,13 @@ jobs: path: | ./e2e/playwright-report retention-days: 7 + trigger-dev-deploy: needs: test if: ${{ github.event_name != 'pull_request' && github.ref_name == 'dev' }} uses: ./.github/workflows/dev-deployment.yml secrets: inherit + trigger-staging-deploy: needs: test if: ${{ github.event_name != 'pull_request' && startsWith(github.ref_name, 'release/') }} diff --git a/.github/workflows/lint-web.yml b/.github/workflows/lint-web.yml index 3f08c96b77..12a74e2dd7 100644 --- a/.github/workflows/lint-web.yml +++ b/.github/workflows/lint-web.yml @@ -3,20 +3,24 @@ on: branches: - main - dev - - 'release/**' - paths: - - "web/**" + - "release/**" + paths-ignore: + - "*.md" + - "LICENSE" pull_request: branches: - main - dev - - 'release/**' - paths: - - "web/**" + - "release/**" + paths-ignore: + - "*.md" + - "LICENSE" jobs: lint-web: - runs-on: [self-hosted, Linux, X64] + runs-on: + - codebuild-defguard-core-runner-${{ github.run_id }}-${{ github.run_attempt }} + steps: - uses: actions/checkout@v4 with: @@ -27,11 +31,11 @@ jobs: - name: install deps working-directory: ./web run: | - npm i -g pnpm + npm i -g npm pnpm pnpm i --frozen-lockfile - name: Lint working-directory: ./web - run: pnpm run lint-ci + run: pnpm run lint - name: Audit working-directory: ./web run: pnpm audit --prod diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a79149cb92..45da7f893d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,10 +53,12 @@ jobs: build-binaries: needs: [create-release] + runs-on: - self-hosted - Linux - X64 + strategy: fail-fast: false matrix: @@ -71,6 +73,10 @@ jobs: - build: freebsd arch: amd64 target: x86_64-unknown-freebsd + + permissions: + contents: write # needed to upload release assets + steps: # Store the version, stripping any v-prefix - name: Write release version @@ -105,14 +111,12 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9 + version: 10 - - name: Use Node.js 20 + - name: Use Node.js 24 uses: actions/setup-node@v4 with: - node-version: 20 - cache: "pnpm" - cache-dependency-path: ./web/pnpm-lock.yaml + node-version: 24 - name: Install frontend dependencies run: pnpm install --ignore-scripts --frozen-lockfile @@ -165,7 +169,7 @@ jobs: - name: Build AMI images for multiple regions if: matrix.build == 'linux' && matrix.arch == 'amd64' run: | - regions=(us-east-1 eu-west-1 ap-northeast-1) + regions=(us-east-1 eu-west-1 ap-northeast-1 eu-central-1) for region in "${regions[@]}"; do echo "Building AMI for region: $region" echo "Running packer validate for $region..." diff --git a/.sqlx/query-770fcf951f69a40e2e9833486425dc105a0411bd634a080391e41f431f966c17.json b/.sqlx/query-06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77.json similarity index 92% rename from .sqlx/query-770fcf951f69a40e2e9833486425dc105a0411bd634a080391e41f431f966c17.json rename to .sqlx/query-06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77.json index 418350329a..dec553bccb 100644 --- a/.sqlx/query-770fcf951f69a40e2e9833486425dc105a0411bd634a080391e41f431f966c17.json +++ b/.sqlx/query-06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled,\n directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match FROM openidprovider WHERE name = $1", + "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled,\n directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key FROM openidprovider WHERE name = $1", "describe": { "columns": [ { @@ -120,6 +120,11 @@ "ordinal": 16, "name": "directory_sync_group_match", "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "jumpcloud_api_key", + "type_info": "Text" } ], "parameters": { @@ -144,8 +149,9 @@ false, true, true, - false + false, + true ] }, - "hash": "770fcf951f69a40e2e9833486425dc105a0411bd634a080391e41f431f966c17" + "hash": "06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77" } diff --git a/.sqlx/query-5e304fafd2e6b526042c2f43e038f6464ef320242782b486f7e17c7742eec1f0.json b/.sqlx/query-07ac05be4850e0154414090784fc40392f423c16cd326716994fcb1f45c84eee.json similarity index 92% rename from .sqlx/query-5e304fafd2e6b526042c2f43e038f6464ef320242782b486f7e17c7742eec1f0.json rename to .sqlx/query-07ac05be4850e0154414090784fc40392f423c16cd326716994fcb1f45c84eee.json index 73ad3046b5..5a629b4c9f 100644 --- a/.sqlx/query-5e304fafd2e6b526042c2f43e038f6464ef320242782b486f7e17c7742eec1f0.json +++ b/.sqlx/query-07ac05be4850e0154414090784fc40392f423c16cd326716994fcb1f45c84eee.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\" FROM \"openidprovider\"", + "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\" FROM \"openidprovider\"", "describe": { "columns": [ { @@ -120,6 +120,11 @@ "ordinal": 16, "name": "directory_sync_group_match: _", "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "jumpcloud_api_key", + "type_info": "Text" } ], "parameters": { @@ -142,8 +147,9 @@ false, true, true, - false + false, + true ] }, - "hash": "5e304fafd2e6b526042c2f43e038f6464ef320242782b486f7e17c7742eec1f0" + "hash": "07ac05be4850e0154414090784fc40392f423c16cd326716994fcb1f45c84eee" } diff --git a/.sqlx/query-1817d2513210c2b128336ecf4fff6c57a40cd9f4d57e192f7cb9e544f8831e8b.json b/.sqlx/query-1817d2513210c2b128336ecf4fff6c57a40cd9f4d57e192f7cb9e544f8831e8b.json new file mode 100644 index 0000000000..3871130f44 --- /dev/null +++ b/.sqlx/query-1817d2513210c2b128336ecf4fff6c57a40cd9f4d57e192f7cb9e544f8831e8b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM \"biometric_auth\" WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "1817d2513210c2b128336ecf4fff6c57a40cd9f4d57e192f7cb9e544f8831e8b" +} diff --git a/.sqlx/query-0d16965b4248d7297b92c0d14ded508dbd1407c8963b3fb240ad24b84fdf5fab.json b/.sqlx/query-187b82f0cc866ff2f1049aa57d9477cbad81d77c2db2b67dca90de198721b483.json similarity index 87% rename from .sqlx/query-0d16965b4248d7297b92c0d14ded508dbd1407c8963b3fb240ad24b84fdf5fab.json rename to .sqlx/query-187b82f0cc866ff2f1049aa57d9477cbad81d77c2db2b67dca90de198721b483.json index c182462846..3576fdb99b 100644 --- a/.sqlx/query-0d16965b4248d7297b92c0d14ded508dbd1407c8963b3fb240ad24b84fdf5fab.json +++ b/.sqlx/query-187b82f0cc866ff2f1049aa57d9477cbad81d77c2db2b67dca90de198721b483.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"client_id\" = $4,\"client_secret\" = $5,\"display_name\" = $6,\"google_service_account_key\" = $7,\"google_service_account_email\" = $8,\"admin_email\" = $9,\"directory_sync_enabled\" = $10,\"directory_sync_interval\" = $11,\"directory_sync_user_behavior\" = $12,\"directory_sync_admin_behavior\" = $13,\"directory_sync_target\" = $14,\"okta_private_jwk\" = $15,\"okta_dirsync_client_id\" = $16,\"directory_sync_group_match\" = $17 WHERE id = $1", + "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"client_id\" = $4,\"client_secret\" = $5,\"display_name\" = $6,\"google_service_account_key\" = $7,\"google_service_account_email\" = $8,\"admin_email\" = $9,\"directory_sync_enabled\" = $10,\"directory_sync_interval\" = $11,\"directory_sync_user_behavior\" = $12,\"directory_sync_admin_behavior\" = $13,\"directory_sync_target\" = $14,\"okta_private_jwk\" = $15,\"okta_dirsync_client_id\" = $16,\"directory_sync_group_match\" = $17,\"jumpcloud_api_key\" = $18 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -54,10 +54,11 @@ }, "Text", "Text", - "TextArray" + "TextArray", + "Text" ] }, "nullable": [] }, - "hash": "0d16965b4248d7297b92c0d14ded508dbd1407c8963b3fb240ad24b84fdf5fab" + "hash": "187b82f0cc866ff2f1049aa57d9477cbad81d77c2db2b67dca90de198721b483" } diff --git a/.sqlx/query-3b3c28a99faeeaf24ff0fc5a3592e8010ceb453fbcf1672b23ef086e76c48958.json b/.sqlx/query-25b07b45cfd25643e750e2ec0fe51bd760fae6db5f5416d017a3f4a24c44c185.json similarity index 73% rename from .sqlx/query-3b3c28a99faeeaf24ff0fc5a3592e8010ceb453fbcf1672b23ef086e76c48958.json rename to .sqlx/query-25b07b45cfd25643e750e2ec0fe51bd760fae6db5f5416d017a3f4a24c44c185.json index b93f27983f..5c8df41994 100644 --- a/.sqlx/query-3b3c28a99faeeaf24ff0fc5a3592e8010ceb453fbcf1672b23ef086e76c48958.json +++ b/.sqlx/query-25b07b45cfd25643e750e2ec0fe51bd760fae6db5f5416d017a3f4a24c44c185.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, user_id, created_at, name, token_hash FROM api_token WHERE token_hash = $1", + "query": "SELECT at.id, user_id, created_at, name, token_hash FROM api_token at JOIN \"user\" ON \"user\".id = user_id WHERE token_hash = $1 AND \"user\".is_active = true", "describe": { "columns": [ { @@ -42,5 +42,5 @@ false ] }, - "hash": "3b3c28a99faeeaf24ff0fc5a3592e8010ceb453fbcf1672b23ef086e76c48958" + "hash": "25b07b45cfd25643e750e2ec0fe51bd760fae6db5f5416d017a3f4a24c44c185" } diff --git a/.sqlx/query-41d50b33737847c6a7639125b3d04d1d499ee1defd1557b08ec0f48d9bbd1ac3.json b/.sqlx/query-41d50b33737847c6a7639125b3d04d1d499ee1defd1557b08ec0f48d9bbd1ac3.json new file mode 100644 index 0000000000..51ac03b6f6 --- /dev/null +++ b/.sqlx/query-41d50b33737847c6a7639125b3d04d1d499ee1defd1557b08ec0f48d9bbd1ac3.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, pub_key, device_id FROM biometric_auth WHERE device_id=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "pub_key", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "device_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "41d50b33737847c6a7639125b3d04d1d499ee1defd1557b08ec0f48d9bbd1ac3" +} diff --git a/.sqlx/query-68009569c58b64947a3b3ce7fbc1f733da54b9660e287cbf44534f7793bf2db6.json b/.sqlx/query-68009569c58b64947a3b3ce7fbc1f733da54b9660e287cbf44534f7793bf2db6.json new file mode 100644 index 0000000000..577736725f --- /dev/null +++ b/.sqlx/query-68009569c58b64947a3b3ce7fbc1f733da54b9660e287cbf44534f7793bf2db6.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"pub_key\",\"device_id\" FROM \"biometric_auth\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "pub_key", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "device_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "68009569c58b64947a3b3ce7fbc1f733da54b9660e287cbf44534f7793bf2db6" +} diff --git a/.sqlx/query-d2c2173b83c2948b01c2571c5a929a3c89e0725d2d2d7a1aa6739f1870a4fd05.json b/.sqlx/query-6c3bbaa998dbb9d0b3771c546b014818139cdfac6ed6c15603f6e6806c63ac6f.json similarity index 92% rename from .sqlx/query-d2c2173b83c2948b01c2571c5a929a3c89e0725d2d2d7a1aa6739f1870a4fd05.json rename to .sqlx/query-6c3bbaa998dbb9d0b3771c546b014818139cdfac6ed6c15603f6e6806c63ac6f.json index 5866d742d6..8b48d798c8 100644 --- a/.sqlx/query-d2c2173b83c2948b01c2571c5a929a3c89e0725d2d2d7a1aa6739f1870a4fd05.json +++ b/.sqlx/query-6c3bbaa998dbb9d0b3771c546b014818139cdfac6ed6c15603f6e6806c63ac6f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match FROM openidprovider LIMIT 1", + "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key FROM openidprovider LIMIT 1", "describe": { "columns": [ { @@ -120,6 +120,11 @@ "ordinal": 16, "name": "directory_sync_group_match", "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "jumpcloud_api_key", + "type_info": "Text" } ], "parameters": { @@ -142,8 +147,9 @@ false, true, true, - false + false, + true ] }, - "hash": "d2c2173b83c2948b01c2571c5a929a3c89e0725d2d2d7a1aa6739f1870a4fd05" + "hash": "6c3bbaa998dbb9d0b3771c546b014818139cdfac6ed6c15603f6e6806c63ac6f" } diff --git a/.sqlx/query-7874a2d0b3a5a6b78bdb22666a95924214dc1b175ba520bf417b63cb598478d9.json b/.sqlx/query-7874a2d0b3a5a6b78bdb22666a95924214dc1b175ba520bf417b63cb598478d9.json new file mode 100644 index 0000000000..d0c08947b8 --- /dev/null +++ b/.sqlx/query-7874a2d0b3a5a6b78bdb22666a95924214dc1b175ba520bf417b63cb598478d9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM oauth2authorizedapp WHERE oauth2client_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7874a2d0b3a5a6b78bdb22666a95924214dc1b175ba520bf417b63cb598478d9" +} diff --git a/.sqlx/query-858c7323ca15f68c7c22c40987d7eb60ad39c2e52f23525bb9a1d656bd45ac2a.json b/.sqlx/query-858c7323ca15f68c7c22c40987d7eb60ad39c2e52f23525bb9a1d656bd45ac2a.json new file mode 100644 index 0000000000..d33d89c315 --- /dev/null +++ b/.sqlx/query-858c7323ca15f68c7c22c40987d7eb60ad39c2e52f23525bb9a1d656bd45ac2a.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"pub_key\",\"device_id\" FROM \"biometric_auth\" WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "pub_key", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "device_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "858c7323ca15f68c7c22c40987d7eb60ad39c2e52f23525bb9a1d656bd45ac2a" +} diff --git a/.sqlx/query-8d638604879f1d86b881c3361721f3169c8431b0860003e4b482f7ef97c9626e.json b/.sqlx/query-8941a709b6223b3ef010a1c41098738c87ab132a63c1736382bca59e882c47e4.json similarity index 52% rename from .sqlx/query-8d638604879f1d86b881c3361721f3169c8431b0860003e4b482f7ef97c9626e.json rename to .sqlx/query-8941a709b6223b3ef010a1c41098738c87ab132a63c1736382bca59e882c47e4.json index d7882586a7..b75191b17e 100644 --- a/.sqlx/query-8d638604879f1d86b881c3361721f3169c8431b0860003e4b482f7ef97c9626e.json +++ b/.sqlx/query-8941a709b6223b3ef010a1c41098738c87ab132a63c1736382bca59e882c47e4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT (SELECT count(*) FROM \"user\") \"users!\", (SELECT count(*) FROM device) \"devices!\", (SELECT count(*) FROM wireguard_network) \"wireguard_networks!\"\n ", + "query": "SELECT (SELECT count(*) FROM \"user\") \"users!\", (SELECT count(*) FROM device WHERE device_type = 'user') \"user_devices!\", (SELECT count(*) FROM device WHERE device_type = 'network') \"network_devices!\",\n (SELECT count(*) FROM wireguard_network) \"wireguard_networks!\"\n ", "describe": { "columns": [ { @@ -10,11 +10,16 @@ }, { "ordinal": 1, - "name": "devices!", + "name": "user_devices!", "type_info": "Int8" }, { "ordinal": 2, + "name": "network_devices!", + "type_info": "Int8" + }, + { + "ordinal": 3, "name": "wireguard_networks!", "type_info": "Int8" } @@ -23,10 +28,11 @@ "Left": [] }, "nullable": [ + null, null, null, null ] }, - "hash": "8d638604879f1d86b881c3361721f3169c8431b0860003e4b482f7ef97c9626e" + "hash": "8941a709b6223b3ef010a1c41098738c87ab132a63c1736382bca59e882c47e4" } diff --git a/.sqlx/query-9cfebbfe0253249ee1b2d65ff6a910766a23aaefc6ba173b1327145fc5280716.json b/.sqlx/query-9cfebbfe0253249ee1b2d65ff6a910766a23aaefc6ba173b1327145fc5280716.json new file mode 100644 index 0000000000..03e8434ef6 --- /dev/null +++ b/.sqlx/query-9cfebbfe0253249ee1b2d65ff6a910766a23aaefc6ba173b1327145fc5280716.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"biometric_auth\" (\"pub_key\",\"device_id\") VALUES ($1,$2) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9cfebbfe0253249ee1b2d65ff6a910766a23aaefc6ba173b1327145fc5280716" +} diff --git a/.sqlx/query-b84b09a440fab66250603e50a3080fc67194a7de7cf7241d938b25f068525411.json b/.sqlx/query-9f98a138560451105b104fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a.json similarity index 92% rename from .sqlx/query-b84b09a440fab66250603e50a3080fc67194a7de7cf7241d938b25f068525411.json rename to .sqlx/query-9f98a138560451105b104fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a.json index 5015312198..f847621f43 100644 --- a/.sqlx/query-b84b09a440fab66250603e50a3080fc67194a7de7cf7241d938b25f068525411.json +++ b/.sqlx/query-9f98a138560451105b104fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\" FROM \"openidprovider\" WHERE id = $1", + "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\" FROM \"openidprovider\" WHERE id = $1", "describe": { "columns": [ { @@ -120,6 +120,11 @@ "ordinal": 16, "name": "directory_sync_group_match: _", "type_info": "TextArray" + }, + { + "ordinal": 17, + "name": "jumpcloud_api_key", + "type_info": "Text" } ], "parameters": { @@ -144,8 +149,9 @@ false, true, true, - false + false, + true ] }, - "hash": "b84b09a440fab66250603e50a3080fc67194a7de7cf7241d938b25f068525411" + "hash": "9f98a138560451105b104fc7a4d3d29e22e58f33e902c06bbf6163ee48ae802a" } diff --git a/.sqlx/query-b0bc054e0913f416d0a3beee4d04372cd8769b97a42369ba399dbecf03f491bb.json b/.sqlx/query-b0bc054e0913f416d0a3beee4d04372cd8769b97a42369ba399dbecf03f491bb.json new file mode 100644 index 0000000000..1967b76ed5 --- /dev/null +++ b/.sqlx/query-b0bc054e0913f416d0a3beee4d04372cd8769b97a42369ba399dbecf03f491bb.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS( SELECT 1 FROM wireguard_network WHERE location_mfa_mode = 'internal'::location_mfa_mode ) \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "b0bc054e0913f416d0a3beee4d04372cd8769b97a42369ba399dbecf03f491bb" +} diff --git a/.sqlx/query-c398c1d38f7343ddf026d8fca19d50c63d1fbcf5aedc1448ab445bf223f6c612.json b/.sqlx/query-c398c1d38f7343ddf026d8fca19d50c63d1fbcf5aedc1448ab445bf223f6c612.json new file mode 100644 index 0000000000..322057ab6d --- /dev/null +++ b/.sqlx/query-c398c1d38f7343ddf026d8fca19d50c63d1fbcf5aedc1448ab445bf223f6c612.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"biometric_auth\" SET \"pub_key\" = $2,\"device_id\" = $3 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c398c1d38f7343ddf026d8fca19d50c63d1fbcf5aedc1448ab445bf223f6c612" +} diff --git a/.sqlx/query-9564c6bf55964238003a93c4047ea956656c9ef58f46ede7bc8225900ade4579.json b/.sqlx/query-d4d76206a3eeb48f4c3e06e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb.json similarity index 91% rename from .sqlx/query-9564c6bf55964238003a93c4047ea956656c9ef58f46ede7bc8225900ade4579.json rename to .sqlx/query-d4d76206a3eeb48f4c3e06e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb.json index 8b25a6c3fb..e28198163d 100644 --- a/.sqlx/query-9564c6bf55964238003a93c4047ea956656c9ef58f46ede7bc8225900ade4579.json +++ b/.sqlx/query-d4d76206a3eeb48f4c3e06e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12, directory_sync_target = $13, okta_private_jwk = $14, okta_dirsync_client_id = $15, directory_sync_group_match = $16 WHERE id = $17", + "query": "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12, directory_sync_target = $13, okta_private_jwk = $14, okta_dirsync_client_id = $15, directory_sync_group_match = $16, jumpcloud_api_key = $17 WHERE id = $18", "describe": { "columns": [], "parameters": { @@ -54,10 +54,11 @@ "Text", "Text", "TextArray", + "Text", "Int8" ] }, "nullable": [] }, - "hash": "9564c6bf55964238003a93c4047ea956656c9ef58f46ede7bc8225900ade4579" + "hash": "d4d76206a3eeb48f4c3e06e53e781bab2a0e2020e33653ef34ab1ea7df67a0cb" } diff --git a/.sqlx/query-406d99b05beaa7cbd8554aadd7a16ceb4e139f131e33a032dd4b719a937de935.json b/.sqlx/query-dce467a600d7b0e51d1b75dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff.json similarity index 85% rename from .sqlx/query-406d99b05beaa7cbd8554aadd7a16ceb4e139f131e33a032dd4b719a937de935.json rename to .sqlx/query-dce467a600d7b0e51d1b75dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff.json index c192b0f873..8902fca66f 100644 --- a/.sqlx/query-406d99b05beaa7cbd8554aadd7a16ceb4e139f131e33a032dd4b719a937de935.json +++ b/.sqlx/query-dce467a600d7b0e51d1b75dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\",\"directory_sync_admin_behavior\",\"directory_sync_target\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING id", + "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\",\"directory_sync_admin_behavior\",\"directory_sync_target\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\",\"jumpcloud_api_key\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING id", "describe": { "columns": [ { @@ -59,12 +59,13 @@ }, "Text", "Text", - "TextArray" + "TextArray", + "Text" ] }, "nullable": [ false ] }, - "hash": "406d99b05beaa7cbd8554aadd7a16ceb4e139f131e33a032dd4b719a937de935" + "hash": "dce467a600d7b0e51d1b75dd5978c56cc1e6b0c6fbf1907cce4bbe0a1bde88ff" } diff --git a/.sqlx/query-e770f574ecb1c8fc700e610334914e72dbffca5448b0aef3e0b0e3f60af6b5f7.json b/.sqlx/query-e770f574ecb1c8fc700e610334914e72dbffca5448b0aef3e0b0e3f60af6b5f7.json new file mode 100644 index 0000000000..e08b8d9d28 --- /dev/null +++ b/.sqlx/query-e770f574ecb1c8fc700e610334914e72dbffca5448b0aef3e0b0e3f60af6b5f7.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT b.id, b.pub_key, b.device_id FROM biometric_auth as b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "pub_key", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "device_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "e770f574ecb1c8fc700e610334914e72dbffca5448b0aef3e0b0e3f60af6b5f7" +} diff --git a/.sqlx/query-fc63c581cfb32138100410208cb68dfad796a246e90d3faac2e136e476314696.json b/.sqlx/query-fc63c581cfb32138100410208cb68dfad796a246e90d3faac2e136e476314696.json new file mode 100644 index 0000000000..8024e17b71 --- /dev/null +++ b/.sqlx/query-fc63c581cfb32138100410208cb68dfad796a246e90d3faac2e136e476314696.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT b.id FROM biometric_auth as b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1 AND b.pub_key = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fc63c581cfb32138100410208cb68dfad796a246e90d3faac2e136e476314696" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dcb63746a5..ebfd8bdfa0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Requires `.sqlx` directory to be present in the root directory of the project. Create the file using: ``` -cargo sqlx prepare -- --lib +cargo sqlx prepare --workspace -- --all-targets --tests ``` 1. Build docker image @@ -55,5 +55,5 @@ Following environment variables can be set to configure orion core service: ### User agents YAML update ``` -curl -Lf https://raw.githubusercontent.com/ua-parser/uap-core/master/regexes.yaml | yq -y '.' > user_agent_header_regexes.yaml +curl -Lf https://raw.githubusercontent.com/ua-parser/uap-core/master/regexes.yaml | yq -y '.' > crates/defguard_core/user_agent_header_regexes.yaml ``` diff --git a/Cargo.lock b/Cargo.lock index 61aabb2118..57464586f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ + "bytes", "crypto-common", "generic-array", ] @@ -88,12 +89,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -105,9 +100,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -135,35 +130,35 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" dependencies = [ "derive_arbitrary", ] @@ -244,9 +239,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -274,40 +269,13 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower 0.5.2", - "tower-layer", - "tower-service", -] - [[package]] name = "axum" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ - "axum-core 0.5.2", + "axum-core", "bytes", "form_urlencoded", "futures-util", @@ -317,7 +285,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit 0.8.4", + "matchit", "memchr", "mime", "percent-encoding", @@ -329,7 +297,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", "tracing", @@ -341,31 +309,11 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff8ee1869817523c8f91c20bf17fd932707f66c2e7e0b0f811b29a227289562" dependencies = [ - "axum 0.8.4", + "axum", "forwarded-header-value", "serde", ] -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", -] - [[package]] name = "axum-core" version = "0.5.2" @@ -392,8 +340,8 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" dependencies = [ - "axum 0.8.4", - "axum-core 0.5.2", + "axum", + "axum-core", "bytes", "cookie", "form_urlencoded", @@ -408,7 +356,7 @@ dependencies = [ "serde", "serde_html_form", "serde_path_to_error", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", ] @@ -476,10 +424,25 @@ dependencies = [ ] [[package]] -name = "bitfield" -version = "0.17.0" +name = "bitfields" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d84268bbf9b487d31fe4b849edbefcd3911422d7a07de855a2da1f70ab3d1c" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f798d2d157e547aa99aab0967df39edd0b70307312b6f8bd2848e6abe40896e0" +checksum = "07c93edde7bb4416c35c85048e34f78999dcb47d199bde3b1d79286156f3e2fb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "thiserror 2.0.16", +] [[package]] name = "bitflags" @@ -489,13 +452,25 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" dependencies = [ "serde", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.10.6" @@ -573,25 +548,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "camellia" version = "0.1.0" @@ -613,10 +569,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -633,9 +590,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" @@ -645,17 +602,16 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -708,9 +664,9 @@ checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18" [[package]] name = "clap" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -718,9 +674,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -730,9 +686,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", @@ -798,12 +754,6 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "convert_case" version = "0.4.0" @@ -1007,6 +957,23 @@ dependencies = [ "syn", ] +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "pkcs8", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "darling" version = "0.20.11" @@ -1059,18 +1026,18 @@ dependencies = [ [[package]] name = "defguard" -version = "1.5.0" +version = "0.0.0" dependencies = [ "anyhow", "bytes", "defguard_core", "defguard_event_logger", "defguard_event_router", + "defguard_version", "dotenvy", "secrecy", "tokio", "tracing", - "tracing-subscriber", ] [[package]] @@ -1079,7 +1046,7 @@ version = "1.5.0" dependencies = [ "anyhow", "argon2", - "axum 0.8.4", + "axum", "axum-client-ip", "axum-extra", "base32", @@ -1088,9 +1055,11 @@ dependencies = [ "chrono", "claims", "clap", + "defguard_version", "defguard_web_ui", - "dotenvy", + "ed25519-dalek", "humantime", + "hyper-util", "ipnetwork", "jsonwebkey", "jsonwebtoken", @@ -1104,10 +1073,8 @@ dependencies = [ "paste", "pgp", "prost", - "prost-build", "pulldown-cmark", "rand 0.8.5", - "rand_core 0.6.4", "regex", "reqwest", "rsa", @@ -1127,18 +1094,19 @@ dependencies = [ "strum", "strum_macros", "tera", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", "tokio", "tokio-stream", "tokio-util", "tonic", - "tonic-build", "tonic-health", + "tonic-prost", + "tonic-prost-build", "totp-lite", + "tower", "tower-http", "tracing", - "tracing-subscriber", "trait-variant", "uaparser", "utoipa", @@ -1149,7 +1117,6 @@ dependencies = [ "webauthn-rs", "webauthn-rs-proto", "x25519-dalek", - "zip 2.4.2", ] [[package]] @@ -1159,10 +1126,9 @@ dependencies = [ "bytes", "chrono", "defguard_core", - "ipnetwork", "serde_json", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tracing", ] @@ -1173,26 +1139,36 @@ version = "0.0.0" dependencies = [ "defguard_core", "defguard_event_logger", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tracing", ] +[[package]] +name = "defguard_version" +version = "0.0.0" +dependencies = [ + "axum", + "http", + "os_info", + "semver", + "serde", + "thiserror 2.0.16", + "tonic", + "tower", + "tracing", + "tracing-subscriber", +] + [[package]] name = "defguard_web_ui" version = "0.0.0" dependencies = [ - "axum 0.8.4", + "axum", "mime_guess", "rust-embed", ] -[[package]] -name = "deflate64" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" - [[package]] name = "der" version = "0.7.10" @@ -1220,9 +1196,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" dependencies = [ "powerfmt", "serde", @@ -1230,9 +1206,9 @@ dependencies = [ [[package]] name = "derive_arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", @@ -1285,18 +1261,18 @@ dependencies = [ [[package]] name = "derive_more" -version = "1.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "1.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", @@ -1384,9 +1360,9 @@ dependencies = [ [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "eax" @@ -1433,6 +1409,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -1455,6 +1432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", + "base64ct", "crypto-bigint", "digest", "ff", @@ -1465,7 +1443,10 @@ dependencies = [ "pkcs8", "rand_core 0.6.4", "sec1", + "serde_json", + "serdect 0.2.0", "subtle", + "tap", "zeroize", ] @@ -1502,12 +1483,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -1523,9 +1504,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1544,6 +1525,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ + "bitvec", "rand_core 0.6.4", "subtle", ] @@ -1554,6 +1536,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1611,9 +1599,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1628,6 +1616,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -1741,9 +1735,9 @@ dependencies = [ [[package]] name = "getopts" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ "unicode-width", ] @@ -1771,7 +1765,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.5+wasi-0.2.4", "wasm-bindgen", ] @@ -1797,7 +1791,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "libc", "libgit2-sys", "log", @@ -1813,8 +1807,8 @@ dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] @@ -1823,7 +1817,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "ignore", "walkdir", ] @@ -1841,9 +1835,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -1851,7 +1845,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.10.0", + "indexmap 2.11.1", "slab", "tokio", "tokio-util", @@ -1882,9 +1876,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", @@ -1897,7 +1891,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", ] [[package]] @@ -1971,7 +1965,7 @@ checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" dependencies = [ "cfg-if", "libc", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2043,13 +2037,14 @@ checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -2057,6 +2052,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -2126,7 +2122,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2", "system-configuration", "tokio", "tower-service", @@ -2261,9 +2257,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2290,7 +2286,7 @@ dependencies = [ "globset", "log", "memchr", - "regex-automata 0.4.9", + "regex-automata", "same-file", "walkdir", "winapi-util", @@ -2309,12 +2305,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "serde", ] @@ -2329,11 +2325,11 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -2369,12 +2365,6 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" -[[package]] -name = "iter-read" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071ed4cc1afd86650602c7b11aa2e1ce30762a1c27193201cb5cee9c6ebb1294" - [[package]] name = "itertools" version = "0.10.5" @@ -2401,9 +2391,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ "getrandom 0.3.3", "libc", @@ -2411,9 +2401,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" dependencies = [ "once_cell", "wasm-bindgen", @@ -2519,9 +2509,9 @@ dependencies = [ [[package]] name = "lettre" -version = "0.11.17" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb2a0354e9ece2fcdcf9fa53417f6de587230c0c248068eb058fa26c4a753179" +checksum = "5cb54db6ff7a89efac87dba5baeac57bb9ccd726b49a9b6f21fb92b3966aaf56" dependencies = [ "async-trait", "base64 0.22.1", @@ -2539,7 +2529,7 @@ dependencies = [ "nom 8.0.0", "percent-encoding", "quoted_printable", - "socket2 0.5.10", + "socket2", "tokio", "tokio-native-tls", "url", @@ -2547,9 +2537,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.174" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libgit2-sys" @@ -2569,6 +2559,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags 2.9.4", + "libc", + "redox_syscall", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -2581,9 +2582,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" dependencies = [ "zlib-rs", ] @@ -2602,9 +2603,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -2614,9 +2615,9 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "litrs" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "lock_api" @@ -2630,9 +2631,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "lru-slab" @@ -2640,34 +2641,13 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -2676,12 +2656,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "matchit" version = "0.8.4" @@ -2813,12 +2787,11 @@ checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "overload", - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -3032,7 +3005,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "foreign-types", "libc", @@ -3090,10 +3063,16 @@ dependencies = [ ] [[package]] -name = "overload" -version = "0.1.1" +name = "os_info" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" +dependencies = [ + "log", + "plist", + "serde", + "windows-sys 0.52.0", +] [[package]] name = "p256" @@ -3201,19 +3180,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a8cb46bdc156b1c90460339ae6bfd45ba0394e5effbaa640badb4987fdc261" - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pem" @@ -3236,9 +3205,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -3247,7 +3216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.16", "ucd-trie", ] @@ -3291,26 +3260,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.10.0", + "indexmap 2.11.1", ] [[package]] name = "pgp" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30249ac8a98b356b473b04bc5358c75a260aa96a295d0743ce752fe7b173f235" +checksum = "f91d320242d9b686612b15526fe38711afdf856e112eaa4775ce25b0d9b12b11" dependencies = [ + "aead", "aes", "aes-gcm", "aes-kw", "argon2", "base64 0.22.1", - "bitfield", + "bitfields", "block-padding", "blowfish", - "bstr", "buffer-redux", "byteorder", + "bytes", "camellia", "cast5", "cfb-mode", @@ -3319,8 +3289,9 @@ dependencies = [ "const-oid", "crc24", "curve25519-dalek", + "cx448", "derive_builder", - "derive_more 1.0.0", + "derive_more 2.0.1", "des", "digest", "dsa", @@ -3333,7 +3304,6 @@ dependencies = [ "hex", "hkdf", "idea", - "iter-read", "k256", "log", "md-5", @@ -3346,6 +3316,7 @@ dependencies = [ "p384", "p521", "rand 0.8.5", + "regex", "ripemd", "rsa", "sha1", @@ -3354,7 +3325,7 @@ dependencies = [ "sha3", "signature", "smallvec", - "thiserror 2.0.12", + "snafu", "twofish", "x25519-dalek", "zeroize", @@ -3457,6 +3428,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plist" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.11.1", + "quick-xml", + "serde", + "time", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3471,9 +3455,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -3495,9 +3479,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn", @@ -3523,18 +3507,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] [[package]] name = "prost" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", "prost-derive", @@ -3542,9 +3526,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck", "itertools 0.14.0", @@ -3555,6 +3539,8 @@ dependencies = [ "prettyplease", "prost", "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", "regex", "syn", "tempfile", @@ -3562,9 +3548,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", "itertools 0.14.0", @@ -3575,9 +3561,9 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" dependencies = [ "prost", ] @@ -3613,7 +3599,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "getopts", "memchr", "pulldown-cmark-escape", @@ -3626,11 +3612,29 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pulldown-cmark-to-cmark" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5b6a0769a491a08b31ea5c62494a8f144ee0987d86d670a8af4df1e1b7cde75" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", @@ -3639,8 +3643,8 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", - "thiserror 2.0.12", + "socket2", + "thiserror 2.0.16", "tokio", "tracing", "web-time", @@ -3648,9 +3652,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.12" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", "getrandom 0.3.3", @@ -3661,7 +3665,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.16", "tinyvec", "tracing", "web-time", @@ -3669,16 +3673,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.13" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3702,6 +3706,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -3763,11 +3773,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", ] [[package]] @@ -3792,53 +3802,38 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" -version = "0.12.22" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", @@ -3873,7 +3868,7 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tokio-util", - "tower 0.5.2", + "tower", "tower-http", "tower-service", "url", @@ -3986,9 +3981,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -4016,22 +4011,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "log", "once_cell", @@ -4051,16 +4046,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.2.0", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", + "security-framework 3.4.0", ] [[package]] @@ -4086,9 +4072,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -4107,11 +4093,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -4154,6 +4140,7 @@ dependencies = [ "der", "generic-array", "pkcs8", + "serdect 0.2.0", "subtle", "zeroize", ] @@ -4174,7 +4161,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -4183,11 +4170,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.2.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4196,9 +4183,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -4209,6 +4196,9 @@ name = "semver" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -4266,7 +4256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" dependencies = [ "form_urlencoded", - "indexmap 2.10.0", + "indexmap 2.11.1", "itoa", "ryu", "serde", @@ -4274,9 +4264,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -4305,13 +4295,13 @@ dependencies = [ [[package]] name = "serde_qs" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" dependencies = [ "percent-encoding", "serde", - "thiserror 1.0.69", + "thiserror 2.0.16", ] [[package]] @@ -4336,7 +4326,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.10.0", + "indexmap 2.11.1", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -4364,13 +4354,33 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.11.1", "itoa", "ryu", "serde", "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -4477,7 +4487,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", ] @@ -4489,9 +4499,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slug" @@ -4513,13 +4523,24 @@ dependencies = [ ] [[package]] -name = "socket2" -version = "0.5.10" +name = "snafu" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" dependencies = [ - "libc", - "windows-sys 0.52.0", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -4581,9 +4602,9 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "hashlink", - "indexmap 2.10.0", + "indexmap 2.11.1", "ipnetwork", "log", "memchr", @@ -4594,7 +4615,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tokio-stream", "tracing", @@ -4648,7 +4669,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags 2.9.4", "byteorder", "bytes", "chrono", @@ -4678,7 +4699,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", "uuid", "whoami", @@ -4692,7 +4713,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags 2.9.4", "byteorder", "chrono", "crc", @@ -4718,7 +4739,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", "uuid", "whoami", @@ -4744,7 +4765,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", "url", "uuid", @@ -4829,18 +4850,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "struct-patch" -version = "0.8.7" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde1b55ce4b9efe4b5c302dea2d0f1297a522963024e160a587a2670c24f3f04" +checksum = "9e986d2cf6e819bd843319120453d837dfdfa31497c3fee4cefa614b2d182d8c" dependencies = [ "struct-patch-derive", ] [[package]] name = "struct-patch-derive" -version = "0.8.7" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac94fea04bf721f57ed7f421e64d3a04858e15708d00e8aa814cad7507427503" +checksum = "68c6387c1c7b53060605101b63d93edca618c6cf7ce61839f2ec2a527419fdb5" dependencies = [ "proc-macro2", "quote", @@ -4876,9 +4897,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -4911,7 +4932,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4926,17 +4947,23 @@ dependencies = [ "libc", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" -version = "3.20.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -4972,11 +4999,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.16", ] [[package]] @@ -4992,9 +5019,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -5012,12 +5039,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -5029,15 +5055,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -5064,9 +5090,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -5079,9 +5105,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.46.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -5091,9 +5117,9 @@ dependencies = [ "parking_lot", "pin-project-lite", "slab", - "socket2 0.5.10", + "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5141,9 +5167,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -5164,20 +5190,19 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.11.1", "toml_datetime", "winnow", ] [[package]] name = "tonic" -version = "0.12.3" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ - "async-stream", "async-trait", - "axum 0.7.9", + "axum", "base64 0.22.1", "bytes", "flate2", @@ -5190,14 +5215,13 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", "rustls-native-certs", - "rustls-pemfile", - "socket2 0.5.10", + "socket2", + "sync_wrapper", "tokio", "tokio-rustls", "tokio-stream", - "tower 0.4.13", + "tower", "tower-layer", "tower-service", "tracing", @@ -5205,29 +5229,54 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.12.3" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" dependencies = [ "prettyplease", "proc-macro2", - "prost-build", - "prost-types", "quote", "syn", ] [[package]] name = "tonic-health" -version = "0.12.3" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1eaf34ddb812120f5c601162d5429933c9b527d901ab0e7f930d3147e33a09b2" +checksum = "2a82868bf299e0a1d2e8dce0dc33a46c02d6f045b2c1f1d6cc8dc3d0bf1812ef" dependencies = [ - "async-stream", "prost", "tokio", "tokio-stream", "tonic", + "tonic-prost", +] + +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", ] [[package]] @@ -5242,26 +5291,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.5", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "tower" version = "0.5.2" @@ -5270,9 +5299,12 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", + "indexmap 2.11.1", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -5284,7 +5316,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "bytes", "futures-core", "futures-util", @@ -5300,7 +5332,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", "tracing", @@ -5364,14 +5396,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -5502,9 +5534,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-normalization" @@ -5557,9 +5589,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -5585,7 +5617,7 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.11.1", "serde", "serde_json", "utoipa-gen", @@ -5610,7 +5642,7 @@ version = "9.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ - "axum 0.8.4", + "axum", "base64 0.22.1", "mime_guess", "regex", @@ -5620,7 +5652,7 @@ dependencies = [ "url", "utoipa", "utoipa-swagger-ui-vendored", - "zip 3.0.0", + "zip", ] [[package]] @@ -5631,9 +5663,9 @@ checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -5725,11 +5757,20 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.5+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4" dependencies = [ - "wit-bindgen-rt", + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.0+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24" +dependencies = [ + "wit-bindgen", ] [[package]] @@ -5740,21 +5781,22 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" dependencies = [ "bumpalo", "log", @@ -5766,9 +5808,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" dependencies = [ "cfg-if", "js-sys", @@ -5779,9 +5821,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5789,9 +5831,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" dependencies = [ "proc-macro2", "quote", @@ -5802,9 +5844,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" dependencies = [ "unicode-ident", ] @@ -5824,9 +5866,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" dependencies = [ "js-sys", "wasm-bindgen", @@ -5954,45 +5996,23 @@ dependencies = [ [[package]] name = "whoami" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "redox_syscall", + "libredox", "wasite", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.61.2" @@ -6001,7 +6021,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -6034,13 +6054,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-registry" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -6051,7 +6077,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -6060,7 +6086,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -6096,7 +6122,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -6132,10 +6167,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -6286,21 +6322,18 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" [[package]] name = "writeable" @@ -6308,6 +6341,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -6337,15 +6379,6 @@ dependencies = [ "time", ] -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yasna" version = "0.4.0" @@ -6381,18 +6414,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", @@ -6453,9 +6486,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", @@ -6473,36 +6506,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "aes", - "arbitrary", - "bzip2", - "constant_time_eq", - "crc32fast", - "crossbeam-utils", - "deflate64", - "displaydoc", - "flate2", - "getrandom 0.3.3", - "hmac", - "indexmap 2.10.0", - "lzma-rs", - "memchr", - "pbkdf2", - "sha1", - "thiserror 2.0.12", - "time", - "xz2", - "zeroize", - "zopfli", - "zstd", -] - [[package]] name = "zip" version = "3.0.0" @@ -6512,16 +6515,16 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap 2.10.0", + "indexmap 2.11.1", "memchr", "zopfli", ] [[package]] name = "zlib-rs" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" [[package]] name = "zopfli" @@ -6534,31 +6537,3 @@ dependencies = [ "log", "simd-adler32", ] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/Cargo.toml b/Cargo.toml index 3a5f0a6d97..d6405991da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ resolver = "2" defguard_core = { path = "./crates/defguard_core", version = "1.5.0" } defguard_event_logger = { path = "./crates/defguard_event_logger", version = "0.0.0" } defguard_event_router = { path = "./crates/defguard_event_router", version = "0.0.0" } +defguard_version = { path = "./crates/defguard_version", version = "0.0.0" } defguard_web_ui = { path = "./crates/defguard_web_ui", version = "0.0.0" } model_derive = { path = "./crates/model_derive", version = "0.0.0" } @@ -35,29 +36,26 @@ chrono = { version = "0.4", default-features = false, features = [ "serde", ] } clap = { version = "4.5", features = ["derive", "env"] } -dotenvy = "0.15" humantime = "2.1" # match version used by sqlx ipnetwork = "0.20" -jsonwebkey = { version = "0.3.5", features = ["pkcs-convert"] } +jsonwebkey = { version = "0.3", features = ["pkcs-convert"] } jsonwebtoken = "9.3" ldap3 = { version = "0.11", default-features = false, features = ["tls"] } lettre = { version = "0.11", features = ["tokio1-native-tls"] } md4 = "0.10" parse_link_header = "0.4" -paste = "1.0.15" -pgp = "0.15" -prost = "0.13" +paste = "1.0" +pgp = { version = "0.16", default-features = false } pulldown-cmark = "0.13" # match version used by sqlx rand = "0.8" -rand_core = { version = "0.6", features = ["getrandom"] } reqwest = { version = "0.12", features = ["json"] } rsa = "0.9" # 0.21.2 causes config parsing errors rust-ini = "=0.21.1" +semver = { version = "1.0", features = ["serde"] } secrecy = { version = "0.10", features = ["serde"] } -semver = "1.0" serde = { version = "1.0", features = ["derive"] } # match version from webauthn-rs-core serde_cbor = { version = "0.12.0-dev", package = "serde_cbor_2" } @@ -73,9 +71,9 @@ sqlx = { version = "0.8", features = [ "uuid", ] } ssh-key = "0.6" -struct-patch = "0.8" -strum = { version = "0.27.1", features = ["derive"] } -strum_macros = "0.27.1" +struct-patch = "0.10" +strum = { version = "0.27", features = ["derive"] } +strum_macros = "0.27" tera = "1.20" thiserror = "2.0" # match axum-extra -> cookies @@ -89,10 +87,14 @@ tokio = { version = "1", features = [ ] } tokio-stream = "0.1" tokio-util = "0.7" -tonic = { version = "0.12", features = ["gzip", "tls-native-roots"] } -tonic-health = "0.12" +tonic = { version = "0.14", features = [ + "gzip", + "tls-native-roots", + "tls-ring", +] } +tonic-health = "0.14" totp-lite = { version = "2.0" } -tower-http = { version = "0.6", features = ["fs", "trace"] } +tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } trait-variant = "0.1" @@ -108,10 +110,6 @@ webauthn-rs = { version = "0.5", features = [ webauthn-rs-proto = "0.5" x25519-dalek = { version = "2.0", features = ["static_secrets"] } -# https://github.com/juhaku/utoipa/issues/1345 -[workspace.dependencies.zip] -version = "=2.4.2" - [profile.release] codegen-units = 1 panic = "abort" diff --git a/Dockerfile b/Dockerfile index bb8956d83e..48ce6c2ce3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ -FROM node:24-alpine AS web +FROM public.ecr.aws/docker/library/node:24-alpine AS web WORKDIR /app -COPY web/package.json web/pnpm-lock.yaml web/.npmrc . +COPY web/package.json web/pnpm-lock.yaml web/.npmrc ./ RUN npm i -g pnpm RUN pnpm install --ignore-scripts --frozen-lockfile COPY web/ . RUN pnpm run generate-translation-types RUN pnpm build -FROM rust:1.85.1 AS chef +FROM public.ecr.aws/docker/library/rust:1 AS chef WORKDIR /build @@ -43,10 +43,10 @@ COPY migrations migrations RUN cargo install --locked --bin defguard --path ./crates/defguard --root /build # run -FROM debian:bookworm-slim +FROM public.ecr.aws/docker/library/debian:13-slim RUN apt-get update -y && \ - apt-get install --no-install-recommends -y ca-certificates libssl-dev && \ - rm -rf /var/lib/apt/lists/* + apt-get install --no-install-recommends -y ca-certificates libssl-dev && \ + rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /build/bin/defguard . ENTRYPOINT ["./defguard"] diff --git a/README.md b/README.md index 6879aa6d49..99e5e6a309 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ See: - [full list of features](https://github.com/defguard/defguard#features) -- [enterprise only features](https://docs.defguard.net/enterprise/all-enteprise-features) +- [enterprise only features](https://docs.defguard.net/enterprise/enterprise-features) ### Defguard makes it easy to manage complex VPN networks in a secure way @@ -180,6 +180,35 @@ The code in this repository is available under a dual licensing model: Please review the [Contributing guide](https://docs.defguard.net/for-developers/contributing) for information on how to get started contributing to the project. You might also find our [environment setup guide](https://docs.defguard.net/for-developers/dev-env-setup) handy. +## Verifiability of releases + +We provide following ways to verify the authenticity and integrity of official releases: + +### Docker Image Verification with Cosign + +All official Docker images are signed using [Cosign](https://docs.sigstore.dev/cosign/overview/). To verify a Docker image: + +1. [Install](https://github.com/sigstore/cosign?tab=readme-ov-file#installation) cosign CLI + +2. Verify the image signature (replace with the tag you want to verify): + ```bash + cosign verify --certificate-identity-regexp="https://github.com/DefGuard/defguard" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + ghcr.io/defguard/defguard: + ``` + +### Release Asset Verification + +All release assets (binaries, packages, etc.) include SHA256 checksums that are automatically generated and published with each GitHub release: + +1. Download the release asset and copy its corresponding checksum from the [releases page](https://github.com/DefGuard/defguard/releases) + +2. Verify the checksum: + ```bash + # Linux/macOS + echo known_sha256_checksum_of_the_file path/to/file | sha256sum --check + ``` + # Built and sponsored by

diff --git a/SECURITY.md b/SECURITY.md index 7818a8ea12..e8d31accc0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,9 +8,17 @@ These versions are currently being supported with security updates: | ------- | ------------------ | | 1.0.x | :x: | | 1.1.x | :x: | -| 1.2.x | :white_check_mark: | +| 1.2.x | :x: | +| 1.3.x | :x: | +| 1.4.x | :x: | +| 1.5.x | :white_check_mark: | ## Reporting a Vulnerability -Please do **not open a Github Issue for security issues you encounter**. -To reporting a vulnerability open a [security advisory here](https://github.com/defguard/defguard/security/advisories/new). +Please do **not open a Github Issue for security issues you encounter**. Either: + +1. Please follow our [Vulnerability Disclosure Process](https://defguard.net/security/#VDP-title), + or +2. Report a vulnerability by openining a [security advisory here](https://github.com/defguard/defguard/security/advisories/new). + + diff --git a/crates/defguard/Cargo.toml b/crates/defguard/Cargo.toml index 5c453e15fe..a9837db037 100644 --- a/crates/defguard/Cargo.toml +++ b/crates/defguard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "defguard" -version = "1.5.0" +version = "0.0.0" edition.workspace = true license-file.workspace = true homepage.workspace = true @@ -12,12 +12,12 @@ rust-version.workspace = true defguard_core = { workspace = true } defguard_event_router = { workspace = true } defguard_event_logger = { workspace = true } +defguard_version = { workspace = true } # external dependencies anyhow = { workspace = true } +bytes = { workspace = true } dotenvy = "0.15" secrecy = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } -bytes = { workspace = true } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 26c8904468..84a176a73d 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -1,6 +1,6 @@ use std::{ fs::read_to_string, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, RwLock}, }; use bytes::Bytes; @@ -18,11 +18,16 @@ use defguard_core::{ limits::update_counts, }, events::{ApiEvent, BidiStreamEvent, GrpcEvent, InternalEvent}, - grpc::{GatewayMap, WorkerState, run_grpc_bidi_stream, run_grpc_server}, + grpc::{ + WorkerState, + gateway::{client_state::ClientMap, map::GatewayMap}, + run_grpc_bidi_stream, run_grpc_server, + }, init_dev_env, init_vpn_location, mail::{Mail, run_mail_handler}, run_web_server, utility_thread::run_utility_thread, + version::IncompatibleComponents, wireguard_peer_disconnect::run_periodic_peer_disconnect, wireguard_stats_purge::run_periodic_stats_purge, }; @@ -30,7 +35,6 @@ use defguard_event_logger::{message::EventLoggerMessage, run_event_logger}; use defguard_event_router::{RouterReceiverSet, run_event_router}; use secrecy::ExposeSecret; use tokio::sync::{broadcast, mpsc::unbounded_channel}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[macro_use] extern crate tracing; @@ -41,17 +45,17 @@ async fn main() -> Result<(), anyhow::Error> { dotenvy::dotenv().ok(); } let config = DefGuardConfig::new(); - SERVER_CONFIG.set(config.clone())?; - // initialize tracing - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| format!("{},h2=info", config.log_level).into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - - info!("Starting ... version v{}", VERSION); + SERVER_CONFIG + .set(config.clone()) + .expect("Failed to initialize server config."); + + // initialize tracing with version formatter + defguard_version::tracing::init( + defguard_version::Version::parse(VERSION)?, + &config.log_level, + )?; + + info!("Starting ... version v{VERSION}"); debug!("Using config: {config:?}"); let pool = init_db( @@ -103,6 +107,9 @@ async fn main() -> Result<(), anyhow::Error> { let worker_state = Arc::new(Mutex::new(WorkerState::new(webhook_tx.clone()))); let gateway_state = Arc::new(Mutex::new(GatewayMap::new())); + let client_state = Arc::new(Mutex::new(ClientMap::new())); + + let incompatible_components: Arc> = Default::default(); // initialize admin user User::init_admin_user(&pool, config.default_admin_password.expose_secret()).await?; @@ -144,17 +151,73 @@ async fn main() -> Result<(), anyhow::Error> { // run services tokio::select! { - res = run_grpc_bidi_stream(pool.clone(), wireguard_tx.clone(), mail_tx.clone(), bidi_event_tx), if config.proxy_url.is_some() => error!("Proxy gRPC stream returned early: {res:?}"), - res = run_grpc_server(Arc::clone(&worker_state), pool.clone(), Arc::clone(&gateway_state), wireguard_tx.clone(), mail_tx.clone(), grpc_cert, grpc_key, failed_logins.clone(), grpc_event_tx) => error!("gRPC server returned early: {res:?}"), - res = run_web_server(worker_state, gateway_state, webhook_tx, webhook_rx, wireguard_tx.clone(), mail_tx.clone(), pool.clone(), failed_logins, api_event_tx) => error!("Web server returned early: {res:?}"), + res = run_grpc_bidi_stream( + pool.clone(), + wireguard_tx.clone(), + mail_tx.clone(), + bidi_event_tx, + Arc::clone(&incompatible_components), + ), if config.proxy_url.is_some() => error!("Proxy gRPC stream returned early: {res:?}"), + res = run_grpc_server( + Arc::clone(&worker_state), + pool.clone(), + Arc::clone(&gateway_state), + client_state, + wireguard_tx.clone(), + mail_tx.clone(), + grpc_cert, + grpc_key, + failed_logins.clone(), + grpc_event_tx, + Arc::clone(&incompatible_components), + ) => error!("gRPC server returned early: {res:?}"), + res = run_web_server( + worker_state, + gateway_state, + webhook_tx, + webhook_rx, + wireguard_tx.clone(), + mail_tx.clone(), + pool.clone(), + failed_logins, + api_event_tx, + incompatible_components, + ) => error!("Web server returned early: {res:?}"), res = run_mail_handler(mail_rx) => error!("Mail handler returned early: {res:?}"), - res = run_periodic_peer_disconnect(pool.clone(), wireguard_tx.clone(), internal_event_tx.clone()) => error!("Periodic peer disconnect task returned early: {res:?}"), - res = run_periodic_stats_purge(pool.clone(), config.stats_purge_frequency.into(), config.stats_purge_threshold.into()), if !config.disable_stats_purge => error!("Periodic stats purge task returned early: {res:?}"), - res = run_periodic_license_check(&pool) => error!("Periodic license check task returned early: {res:?}"), - res = run_utility_thread(&pool, wireguard_tx.clone()) => error!("Utility thread returned early: {res:?}"), - res = run_event_router(RouterReceiverSet::new(api_event_rx, grpc_event_rx, bidi_event_rx, internal_event_rx), event_logger_tx, wireguard_tx, mail_tx, activity_log_stream_reload_notify.clone()) => error!("Event router returned early: {res:?}"), - res = run_event_logger(pool.clone(), event_logger_rx, activity_log_messages_tx.clone()) => error!("Activity log event logger returned early: {res:?}"), - res = run_activity_log_stream_manager(pool.clone(), activity_log_stream_reload_notify.clone(), activity_log_messages_rx) => error!("Activity log stream manager returned early: {res:?}"), + res = run_periodic_peer_disconnect( + pool.clone(), + wireguard_tx.clone(), + internal_event_tx.clone() + ) => error!("Periodic peer disconnect task returned early: {res:?}"), + res = run_periodic_stats_purge( + pool.clone(), + config.stats_purge_frequency.into(), + config.stats_purge_threshold.into() + ), if !config.disable_stats_purge => + error!("Periodic stats purge task returned early: {res:?}"), + res = run_periodic_license_check(&pool) => + error!("Periodic license check task returned early: {res:?}"), + res = run_utility_thread(&pool, wireguard_tx.clone()) => + error!("Utility thread returned early: {res:?}"), + res = run_event_router( + RouterReceiverSet::new( + api_event_rx, + grpc_event_rx, + bidi_event_rx, + internal_event_rx + ), + event_logger_tx, + wireguard_tx, + mail_tx, + activity_log_stream_reload_notify.clone() + ) => error!("Event router returned early: {res:?}"), + res = run_event_logger(pool.clone(), event_logger_rx, activity_log_messages_tx.clone()) => + error!("Activity log event logger returned early: {res:?}"), + res = run_activity_log_stream_manager( + pool.clone(), + activity_log_stream_reload_notify.clone(), + activity_log_messages_rx + ) => error!("Activity log stream manager returned early: {res:?}"), } Ok(()) diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index 0e07be4195..acd2a5d78c 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true [dependencies] # internal crates defguard_web_ui = { workspace = true } +defguard_version = { workspace = true } model_derive = { workspace = true } # external dependencies @@ -22,7 +23,6 @@ base32 = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } clap = { workspace = true } -dotenvy = { workspace = true } humantime = { workspace = true } # match version used by sqlx ipnetwork = { workspace = true } @@ -37,11 +37,10 @@ openidconnect = { version = "4.0", default-features = false, optional = true, fe parse_link_header = { workspace = true } paste = { workspace = true } pgp = { workspace = true } -prost = { workspace = true } +prost = "0.14" pulldown-cmark = { workspace = true } # match version used by sqlx rand = { workspace = true } -rand_core = { workspace = true } reqwest = { workspace = true } rsa = { workspace = true } rust-ini = { workspace = true } @@ -66,10 +65,10 @@ tokio-stream = { workspace = true } tokio-util = { workspace = true } tonic = { workspace = true } tonic-health = { workspace = true } +tonic-prost = "0.14" totp-lite = { workspace = true } tower-http = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { workspace = true } trait-variant = { workspace = true } uaparser = { workspace = true } # openapi @@ -83,14 +82,13 @@ x25519-dalek = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } bytes = { workspace = true } - -# https://github.com/juhaku/utoipa/issues/1345 -[dependencies.zip] -version = "=2.4.2" +ed25519-dalek = { version = "2.2", features = ["rand_core"] } +tower = "0.5" [dev-dependencies] bytes = "1.6" claims = "0.8" +hyper-util = "0.1" matches = "0.1" regex = "1.10" reqwest = { version = "0.12", features = [ @@ -100,12 +98,12 @@ reqwest = { version = "0.12", features = [ "rustls-tls", "stream", ], default-features = false } -serde_qs = "0.13" +serde_qs = "0.15" +tower = "0.5" webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] } [build-dependencies] -prost-build = "0.13" -tonic-build = "0.12" +tonic-prost-build = "0.14" vergen-git2 = { version = "1.0", features = ["build"] } [features] diff --git a/crates/defguard_core/build.rs b/crates/defguard_core/build.rs index 79b6db8f42..a4c2030a96 100644 --- a/crates/defguard_core/build.rs +++ b/crates/defguard_core/build.rs @@ -5,30 +5,29 @@ fn main() -> Result<(), Box> { let git2 = Git2Builder::default().branch(true).sha(true).build()?; Emitter::default().add_instructions(&git2)?.emit()?; - let mut config = prost_build::Config::new(); - config.protoc_arg("--experimental_allow_proto3_optional"); - config.type_attribute( - "license.LicenseLimits", - "#[derive(serde::Serialize, serde::Deserialize)]", - ); - tonic_build::configure().compile_protos_with_config( - config, - &[ - "../../proto/core/auth.proto", - "../../proto/core/proxy.proto", - "../../proto/worker/worker.proto", - "../../proto/wireguard/gateway.proto", - "../../proto/enterprise/firewall/firewall.proto", - "src/enterprise/proto/license.proto", - ], - &[ - "../../proto/core", - "../../proto/worker", - "../../proto/wireguard", - "../../proto/enterprise/firewall", - "src/enterprise/proto", - ], - )?; + tonic_prost_build::configure() + .protoc_arg("--experimental_allow_proto3_optional") + .type_attribute( + "license.LicenseLimits", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) + .compile_protos( + &[ + "../../proto/core/auth.proto", + "../../proto/core/proxy.proto", + "../../proto/worker/worker.proto", + "../../proto/wireguard/gateway.proto", + "../../proto/enterprise/firewall/firewall.proto", + "src/enterprise/proto/license.proto", + ], + &[ + "../../proto/core", + "../../proto/worker", + "../../proto/wireguard", + "../../proto/enterprise/firewall", + "src/enterprise/proto", + ], + )?; println!("cargo:rerun-if-changed=../../migrations"); println!("cargo:rerun-if-changed=../../proto"); println!("cargo:rerun-if-changed=src/enterprise"); diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index a24585aa27..3930b72c6a 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use axum::extract::FromRef; use axum_extra::extract::cookie::Key; @@ -23,8 +23,11 @@ use crate::{ grpc::gateway::{send_multiple_wireguard_events, send_wireguard_event}, mail::Mail, server_config, + version::IncompatibleComponents, }; +const X_DEFGUARD_EVENT: &str = "x-defguard-event"; + #[derive(Clone)] pub struct AppState { pub pool: PgPool, @@ -35,6 +38,7 @@ pub struct AppState { pub failed_logins: Arc>, key: Key, pub event_tx: UnboundedSender, + pub incompatible_components: Arc>, } impl AppState { @@ -66,7 +70,7 @@ impl AppState { match reqwest_client .post(&webhook.url) .bearer_auth(&webhook.token) - .header("x-defguard-event", event) + .header(X_DEFGUARD_EVENT, event) .json(&payload) .send() .await @@ -111,6 +115,7 @@ impl AppState { mail_tx: UnboundedSender, failed_logins: Arc>, event_tx: UnboundedSender, + incompatible_components: Arc>, ) -> Self { spawn(Self::handle_triggers(pool.clone(), rx)); @@ -140,6 +145,7 @@ impl AppState { failed_logins, key, event_tx, + incompatible_components, } } } diff --git a/crates/defguard_core/src/auth/mod.rs b/crates/defguard_core/src/auth/mod.rs index bca0b2dbc3..b0fd990e20 100644 --- a/crates/defguard_core/src/auth/mod.rs +++ b/crates/defguard_core/src/auth/mod.rs @@ -24,7 +24,7 @@ use crate::{ appstate::AppState, db::{ Group, Id, OAuth2AuthorizedApp, OAuth2Token, Session, SessionState, User, - models::group::Permission, + models::{group::Permission, oauth2client::OAuth2Client}, }, enterprise::{db::models::api_tokens::ApiToken, is_enterprise_enabled}, error::WebError, @@ -303,8 +303,80 @@ macro_rules! role { role!(AdminRole, Permission::IsAdmin); +#[derive(Debug)] +pub(crate) struct UserClaims { + pub email: Option, + pub family_name: Option, + pub given_name: Option, + pub name: Option, + pub phone_number: Option, + pub preferred_username: Option, + pub sub: String, +} + +fn get_available_scopes<'a>( + all_scopes: &'a [String], + requested_scopes: &'a [String], +) -> Vec<&'a str> { + let mut scopes = Vec::new(); + for scope in requested_scopes { + if all_scopes.contains(scope) { + scopes.push(scope.as_str()); + } + } + scopes +} + +impl UserClaims { + pub fn from_user( + user: &User, + oauth_client: &OAuth2Client, + oauth_token: &OAuth2Token, + ) -> Self { + let token_scopes = oauth_token + .scope + .split_whitespace() + .map(String::from) + .collect::>(); + let scopes = get_available_scopes(&oauth_client.scope, &token_scopes); + Self { + email: if scopes.contains(&"email") { + Some(user.email.clone()) + } else { + None + }, + family_name: if scopes.contains(&"profile") { + Some(user.last_name.clone()) + } else { + None + }, + given_name: if scopes.contains(&"profile") { + Some(user.first_name.clone()) + } else { + None + }, + name: if scopes.contains(&"profile") { + Some(user.name()) + } else { + None + }, + phone_number: if scopes.contains(&"phone") { + user.phone.clone() + } else { + None + }, + preferred_username: if scopes.contains(&"profile") { + Some(user.username.clone()) + } else { + None + }, + sub: user.username.clone(), + } + } +} + // User authenticated by a valid access token -pub struct AccessUserInfo(pub(crate) User); +pub struct AccessUserInfo(pub(crate) UserClaims); impl FromRequestParts for AccessUserInfo where @@ -339,7 +411,22 @@ where if let Ok(Some(user)) = User::find_by_id(&appstate.pool, authorized_app.user_id).await { - return Ok(AccessUserInfo(user)); + if let Some(client) = OAuth2Client::find_by_id( + &appstate.pool, + authorized_app.oauth2client_id, + ) + .await? + { + return Ok(AccessUserInfo(UserClaims::from_user( + &user, + &client, + &oauth2token, + ))); + } else { + return Err(WebError::Authorization( + "OAuth2 client not found".into(), + )); + } } } Ok(None) => { @@ -363,3 +450,71 @@ where Err(WebError::Authorization("Invalid session".into())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_available_scopes() { + // All requested scopes are available + let all_scopes = vec![ + "email".to_string(), + "profile".to_string(), + "phone".to_string(), + ]; + let requested_scopes = vec!["email".to_string(), "profile".to_string()]; + let result = get_available_scopes(&all_scopes, &requested_scopes); + assert_eq!(result, vec!["email", "profile"]); + + // Some requested scopes are not available + let all_scopes = vec!["email".to_string(), "profile".to_string()]; + let requested_scopes = vec![ + "email".to_string(), + "phone".to_string(), + "profile".to_string(), + ]; + let result = get_available_scopes(&all_scopes, &requested_scopes); + assert_eq!(result, vec!["email", "profile"]); + + // No requested scopes + let all_scopes = vec!["email".to_string(), "profile".to_string()]; + let requested_scopes = vec![]; + let result = get_available_scopes(&all_scopes, &requested_scopes); + assert_eq!(result, Vec::<&str>::new()); + + // No available scopes + let all_scopes = vec![]; + let requested_scopes = vec!["email".to_string(), "profile".to_string()]; + let result = get_available_scopes(&all_scopes, &requested_scopes); + assert_eq!(result, Vec::<&str>::new()); + + // Both empty + let all_scopes = vec![]; + let requested_scopes = vec![]; + let result = get_available_scopes(&all_scopes, &requested_scopes); + assert_eq!(result, Vec::<&str>::new()); + + // Duplicate requested scopes + let all_scopes = vec!["email".to_string(), "profile".to_string()]; + let requested_scopes = vec![ + "email".to_string(), + "email".to_string(), + "profile".to_string(), + ]; + let result = get_available_scopes(&all_scopes, &requested_scopes); + assert_eq!(result, vec!["email", "email", "profile"]); + + // Case sensitivity + let all_scopes = vec!["email".to_string(), "profile".to_string()]; + let requested_scopes = vec!["Email".to_string(), "PROFILE".to_string()]; + let result = get_available_scopes(&all_scopes, &requested_scopes); + assert_eq!(result, Vec::<&str>::new()); + + // Single scope match + let all_scopes = vec!["email".to_string()]; + let requested_scopes = vec!["email".to_string()]; + let result = get_available_scopes(&all_scopes, &requested_scopes); + assert_eq!(result, vec!["email"]); + } +} diff --git a/crates/defguard_core/src/config.rs b/crates/defguard_core/src/config.rs index c03d493708..e3cf365da5 100644 --- a/crates/defguard_core/src/config.rs +++ b/crates/defguard_core/src/config.rs @@ -233,7 +233,7 @@ impl DefGuardConfig { } // Check if cookie domain value was provided. - // If not generate it based on URL. + // If not, generate it based on URL. fn validate_cookie_domain(&mut self) { if self.cookie_domain.is_none() { self.cookie_domain = Some( diff --git a/crates/defguard_core/src/db/mod.rs b/crates/defguard_core/src/db/mod.rs index 5f01f92dd3..1dcc96ec1c 100644 --- a/crates/defguard_core/src/db/mod.rs +++ b/crates/defguard_core/src/db/mod.rs @@ -1,10 +1,10 @@ pub mod models; -use crate::MIGRATOR; - use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions}; use utoipa::ToSchema; +use crate::MIGRATOR; + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema, Eq, Default, Hash)] pub struct NoId; pub type Id = i64; diff --git a/crates/defguard_core/src/db/models/biometric_auth.rs b/crates/defguard_core/src/db/models/biometric_auth.rs new file mode 100644 index 0000000000..1ac93a4e7a --- /dev/null +++ b/crates/defguard_core/src/db/models/biometric_auth.rs @@ -0,0 +1,227 @@ +use base64::{Engine, engine::general_purpose, prelude::BASE64_STANDARD}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use model_derive::Model; +use sqlx::{PgExecutor, query, query_as}; +use thiserror::Error; + +use crate::{ + db::{Id, NoId}, + random::gen_alphanumeric, +}; + +#[derive(Error, Debug)] +pub enum BiometricAuthError { + #[error("Public key is not valid ed25519")] + InvalidPublicKey, + #[error("Signature invalid")] + InvalidSignature, + #[error("Verification of submitted challenge failed. {0}")] + ChallengeFailed(String), + #[error("Base64 decoding failed. {0}")] + Base64DecodeError(#[from] base64::DecodeError), + #[error("Challenge had no owner")] + ChallengeNotOwned, +} + +impl From for tonic::Status { + fn from(value: BiometricAuthError) -> Self { + Self::invalid_argument(value.to_string()) + } +} + +#[derive(Model, Clone)] +#[table(biometric_auth)] +pub struct BiometricAuth { + pub id: I, + pub pub_key: String, + pub device_id: Id, +} + +impl BiometricAuth { + #[must_use] + pub fn new(device_id: Id, pub_key: String) -> Self { + Self { + id: NoId, + device_id, + pub_key, + } + } + + pub fn validate_pubkey(pub_key: &str) -> Result<(), BiometricAuthError> { + let decoded = BASE64_STANDARD.decode(pub_key)?; + if decoded.len() != ed25519_dalek::PUBLIC_KEY_LENGTH { + return Err(BiometricAuthError::InvalidPublicKey); + } + Ok(()) + } +} + +impl BiometricAuth { + pub(crate) async fn find_by_device_id<'e, E>( + executor: E, + device_id: Id, + ) -> Result, sqlx::Error> + where + E: PgExecutor<'e>, + { + query_as!( + Self, + "SELECT id, pub_key, device_id FROM biometric_auth WHERE device_id=$1", + &device_id + ) + .fetch_optional(executor) + .await + } + + pub(crate) async fn verify_owner<'e, E>( + executor: E, + user_id: Id, + pub_key: &str, + ) -> Result + where + E: PgExecutor<'e>, + { + let q_result = query!( + "SELECT b.id FROM biometric_auth as b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1 AND b.pub_key = $2", + user_id, + pub_key + ) + .fetch_optional(executor) + .await?; + Ok(q_result.is_some()) + } + + pub(crate) async fn find_by_user_id<'e, E>( + executor: E, + user_id: Id, + ) -> Result, sqlx::Error> + where + E: PgExecutor<'e>, + { + query_as!( + Self, + "SELECT b.id, b.pub_key, b.device_id FROM biometric_auth as b JOIN device d ON b.device_id = d.id WHERE d.user_id = $1", &user_id + ) + .fetch_all(executor) + .await + } +} + +#[derive(Clone, Debug)] +pub struct BiometricChallenge { + pub auth_pub_key: Option, + pub challenge: String, +} + +fn decode_pub_key(public_key: &str) -> Result { + let pub_bytes: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] = general_purpose::STANDARD + .decode(public_key) + .map_err(|_| BiometricAuthError::InvalidPublicKey)? + .try_into() + .map_err(|_| BiometricAuthError::InvalidPublicKey)?; + let verifying_key = + VerifyingKey::from_bytes(&pub_bytes).map_err(|_| BiometricAuthError::InvalidPublicKey)?; + Ok(verifying_key) +} + +impl BiometricChallenge { + pub fn new_with_owner(pub_key: &str) -> Result { + let _ = decode_pub_key(pub_key)?; + let mut res = Self::new(); + res.auth_pub_key = Some(pub_key.to_string()); + Ok(res) + } + + #[must_use] + pub fn new() -> Self { + let challenge = gen_alphanumeric(44); + Self { + challenge, + auth_pub_key: None, + } + } + + pub fn verify( + &self, + signed_challenge: &str, + owner: Option, + ) -> Result<(), BiometricAuthError> { + if let Some(auth_pub_key) = owner { + return verify(signed_challenge, auth_pub_key.as_str(), &self.challenge); + } + if let Some(auth_pub_key) = &self.auth_pub_key { + return verify(signed_challenge, auth_pub_key.as_str(), &self.challenge); + } + Err(BiometricAuthError::ChallengeNotOwned) + } +} + +fn verify( + signature: &str, + public_key: &str, + original_challenge: &str, +) -> Result<(), BiometricAuthError> { + let verifying_key = decode_pub_key(public_key)?; + let sig_bytes: [u8; ed25519_dalek::SIGNATURE_LENGTH] = general_purpose::STANDARD + .decode(signature) + .map_err(|_| BiometricAuthError::InvalidSignature)? + .try_into() + .map_err(|_| BiometricAuthError::InvalidSignature)?; + let signature = Signature::from_bytes(&sig_bytes); + verifying_key + .verify(original_challenge.as_bytes(), &signature) + .map_err(|_| BiometricAuthError::InvalidSignature) +} + +#[cfg(test)] +mod test { + use base64::engine::general_purpose; + use ed25519_dalek::Signer; + use matches::assert_matches; + + use super::*; + + #[test] + fn test_verify_valid_sig() { + let mut csprng = rand::rngs::OsRng; + let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng); + let challenge = "test-challenge"; + let signed = signing_key.sign(challenge.as_bytes()); + let serialized_signature = BASE64_STANDARD.encode(signed.to_bytes()); + let serialized_pub_key = BASE64_STANDARD.encode(signing_key.verifying_key().as_bytes()); + + assert_matches!( + verify(&serialized_signature, &serialized_pub_key, challenge), + Ok(()) + ); + } + + #[test] + fn test_verify_invalid_signature() { + let mut csprng = rand::rngs::OsRng; + let signing_key = ed25519_dalek::SigningKey::generate(&mut csprng); + let challenge = "test-challenge"; + + let bad_signature = [0u8; ed25519_dalek::SIGNATURE_LENGTH]; + let signature_b64 = general_purpose::STANDARD.encode(bad_signature); + let public_key_b64 = + general_purpose::STANDARD.encode(signing_key.verifying_key().as_bytes()); + + let result = verify(&signature_b64, &public_key_b64, challenge); + + assert_matches!(result, Err(BiometricAuthError::InvalidSignature)); + } + + #[test] + fn test_verify_invalid_public_key() { + let challenge = "test-challenge"; + let signature = [0u8; ed25519_dalek::SIGNATURE_LENGTH]; + let signature_b64 = general_purpose::STANDARD.encode(signature); + + let bad_pub_key = general_purpose::STANDARD.encode([1, 2, 3]); + + let result = verify(&signature_b64, &bad_pub_key, challenge); + + assert_matches!(result, Err(BiometricAuthError::InvalidPublicKey)); + } +} diff --git a/crates/defguard_core/src/db/models/device.rs b/crates/defguard_core/src/db/models/device.rs index 84785f64f5..8977f70288 100644 --- a/crates/defguard_core/src/db/models/device.rs +++ b/crates/defguard_core/src/db/models/device.rs @@ -46,9 +46,10 @@ pub struct DeviceConfig { // The type of a device: // User: A device of a user, which may be in multiple networks, e.g. a laptop -// Network: A standalone device added by a user permamently bound to one network, e.g. a printer +// Network: A stand-alone device added by a user permanently bound to one network, e.g. a printer #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema, Type)] #[sqlx(type_name = "device_type", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum DeviceType { User, Network, diff --git a/crates/defguard_core/src/db/models/enrollment.rs b/crates/defguard_core/src/db/models/enrollment.rs index a2fd97b8ff..8204991123 100644 --- a/crates/defguard_core/src/db/models/enrollment.rs +++ b/crates/defguard_core/src/db/models/enrollment.rs @@ -1,7 +1,7 @@ use chrono::{NaiveDateTime, TimeDelta, Utc}; use reqwest::Url; use sqlx::{Error as SqlxError, PgConnection, PgExecutor, PgPool, query, query_as}; -use tera::{Context, Tera}; +use tera::Context; use thiserror::Error; use tokio::sync::mpsc::UnboundedSender; use tonic::{Code, Status}; @@ -13,7 +13,7 @@ use crate::{ mail::Mail, random::gen_alphanumeric, server_config, - templates::{self, TemplateError}, + templates::{self, TemplateError, safe_tera}, }; pub static ENROLLMENT_TOKEN_TYPE: &str = "ENROLLMENT"; @@ -317,7 +317,7 @@ impl Token { /// - admin_last_name /// - admin_email /// - admin_phone - pub async fn get_welcome_message_context( + async fn get_welcome_message_context( &self, transaction: &mut PgConnection, ) -> Result { @@ -355,7 +355,7 @@ impl Token { let settings = Settings::get_current_settings(); // load configured content as template - let mut tera = Tera::default(); + let mut tera = safe_tera(); tera.add_raw_template("welcome_page", &settings.enrollment_welcome_message()?)?; let context = self.get_welcome_message_context(&mut *transaction).await?; @@ -373,7 +373,7 @@ impl Token { let settings = Settings::get_current_settings(); // load configured content as template - let mut tera = Tera::default(); + let mut tera = safe_tera(); tera.add_raw_template("welcome_email", &settings.enrollment_welcome_email()?)?; let context = self.get_welcome_message_context(&mut *transaction).await?; diff --git a/crates/defguard_core/src/db/models/group.rs b/crates/defguard_core/src/db/models/group.rs index 12c79e2b34..6dadfbf904 100644 --- a/crates/defguard_core/src/db/models/group.rs +++ b/crates/defguard_core/src/db/models/group.rs @@ -110,7 +110,7 @@ impl Group { .await } - pub(crate) async fn find_by_permission<'e, E>( + pub async fn find_by_permission<'e, E>( executor: E, permission: Permission, ) -> Result, SqlxError> diff --git a/crates/defguard_core/src/db/models/mod.rs b/crates/defguard_core/src/db/models/mod.rs index 2088d31a2d..265c027f6b 100644 --- a/crates/defguard_core/src/db/models/mod.rs +++ b/crates/defguard_core/src/db/models/mod.rs @@ -2,6 +2,7 @@ pub mod activity_log; #[cfg(feature = "openid")] pub mod auth_code; pub mod authentication_key; +pub mod biometric_auth; pub mod device; pub mod device_login; pub mod enrollment; @@ -33,6 +34,7 @@ use self::{ user::{MFAMethod, User}, }; use super::{Group, Id}; +use crate::db::models::biometric_auth::BiometricAuth; #[cfg(feature = "openid")] #[derive(Deserialize, Serialize)] @@ -85,6 +87,7 @@ pub struct GroupDiff { } impl GroupDiff { + #[must_use] pub fn changed(&self) -> bool { !self.added.is_empty() || !self.removed.is_empty() } @@ -203,6 +206,7 @@ pub struct UserDetails { pub user: UserInfo, #[serde(default)] pub devices: Vec, + pub biometric_enabled_devices: Vec, #[serde(default)] pub security_keys: Vec, } @@ -211,11 +215,16 @@ impl UserDetails { pub async fn from_user(pool: &PgPool, user: &User) -> Result { let devices = user.user_devices(pool).await?; let security_keys = user.security_keys(pool).await?; - + let biometric_enabled_devices = BiometricAuth::find_by_user_id(pool, user.id) + .await? + .iter() + .map(|a| a.device_id) + .collect::>(); Ok(Self { user: UserInfo::from_user(pool, user).await?, devices, security_keys, + biometric_enabled_devices, }) } } @@ -232,11 +241,14 @@ impl MFAInfo { pub async fn for_user(pool: &PgPool, user: &User) -> Result, SqlxError> { query_as!( Self, - "SELECT mfa_method \"mfa_method: _\", totp_enabled totp_available, email_mfa_enabled email_available, \ + "SELECT mfa_method \"mfa_method: _\", totp_enabled totp_available, \ + email_mfa_enabled email_available, \ (SELECT count(*) > 0 FROM webauthn WHERE user_id = $1) \"webauthn_available!\" \ FROM \"user\" WHERE \"user\".id = $1", user.id - ).fetch_optional(pool).await + ) + .fetch_optional(pool) + .await } #[must_use] diff --git a/crates/defguard_core/src/db/models/oauth2client.rs b/crates/defguard_core/src/db/models/oauth2client.rs index 535bc8d628..ce3561e601 100644 --- a/crates/defguard_core/src/db/models/oauth2client.rs +++ b/crates/defguard_core/src/db/models/oauth2client.rs @@ -1,5 +1,5 @@ use model_derive::Model; -use sqlx::{Error as SqlxError, PgPool, query_as}; +use sqlx::{Error as SqlxError, PgExecutor, PgPool, query_as}; use super::NewOpenIDClient; use crate::{ @@ -55,20 +55,36 @@ impl OAuth2Client { impl OAuth2Client { /// Find client by 'client_id`. - pub(crate) async fn find_by_client_id( - pool: &PgPool, + pub(crate) async fn find_by_client_id<'e, E>( + executor: E, client_id: &str, - ) -> Result, SqlxError> { + ) -> Result, SqlxError> + where + E: PgExecutor<'e>, + { query_as!( Self, "SELECT id, client_id, client_secret, redirect_uri, scope, name, enabled \ FROM oauth2client WHERE client_id = $1", client_id ) - .fetch_optional(pool) + .fetch_optional(executor) .await } + pub(crate) async fn clear_authorizations<'e, E>(&self, executor: E) -> Result<(), SqlxError> + where + E: PgExecutor<'e>, + { + sqlx::query!( + "DELETE FROM oauth2authorizedapp WHERE oauth2client_id = $1", + self.id + ) + .execute(executor) + .await?; + Ok(()) + } + /// Find using `client_id` and `client_secret`; must be `enabled`. pub(crate) async fn find_by_auth( pool: &PgPool, diff --git a/crates/defguard_core/src/db/models/settings.rs b/crates/defguard_core/src/db/models/settings.rs index 9eb88aa8a5..d74ebd49ee 100644 --- a/crates/defguard_core/src/db/models/settings.rs +++ b/crates/defguard_core/src/db/models/settings.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use sqlx::{PgExecutor, PgPool, Type, query, query_as}; use struct_patch::Patch; use thiserror::Error; +use uuid::Uuid; use crate::{enterprise::ldap::sync::SyncStatus, global_value, secret::SecretStringWrapper}; @@ -10,7 +11,7 @@ global_value!(SETTINGS, Option, None, set_settings, get_settings); /// Initializes global `SETTINGS` struct at program startup pub async fn initialize_current_settings(pool: &PgPool) -> Result<(), sqlx::Error> { - debug!("Initializing global settings strut"); + debug!("Initializing global settings struct"); if let Some(settings) = Settings::get(pool).await? { set_settings(Some(settings)); } else { @@ -89,7 +90,7 @@ pub struct Settings { pub enrollment_use_welcome_message_as_email: bool, // Instance UUID needed for desktop client #[serde(skip)] - pub uuid: uuid::Uuid, + pub uuid: Uuid, // LDAP pub ldap_url: Option, pub ldap_bind_username: Option, @@ -145,8 +146,8 @@ impl Settings { ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, \ ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, \ ldap_group_member_attr, ldap_member_attr, openid_create_account, \ - license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, \ - gateway_disconnect_notifications_inactivity_threshold, \ + license, gateway_disconnect_notifications_enabled, ldap_use_starttls, \ + ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, \ gateway_disconnect_notifications_reconnect_notification_enabled, \ ldap_sync_status \"ldap_sync_status: SyncStatus\", \ ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, \ @@ -160,9 +161,14 @@ impl Settings { } /// Checks if given settings are correct - pub fn validate(&self) -> Result<(), SettingsValidationError> { + pub fn validate(&mut self) -> Result<(), SettingsValidationError> { debug!("Validating settings: {self:?}"); - // check if gateway disconnect notifications can be enabled, since it requires SMTP to be configured + if self.uuid.is_nil() { + warn!("Detected empty UUID in settings. Generating a new one."); + self.uuid = Uuid::new_v4(); + } + // Check if gateway disconnect notifications can be enabled, since it requires SMTP to be + // configured. if self.gateway_disconnect_notifications_enabled && !self.smtp_configured() { warn!("Cannot enable gateway disconnect notifications. SMTP is not configured."); return Err(SettingsValidationError::CannotEnableGatewayNotifications); @@ -326,6 +332,7 @@ impl Settings { && self.smtp_sender != Some(String::new()) } + #[must_use] pub fn ldap_using_username_as_rdn(&self) -> bool { self.ldap_user_rdn_attr .as_deref() diff --git a/crates/defguard_core/src/db/models/user.rs b/crates/defguard_core/src/db/models/user.rs index 80df0cb658..d2e02ee1d5 100644 --- a/crates/defguard_core/src/db/models/user.rs +++ b/crates/defguard_core/src/db/models/user.rs @@ -80,6 +80,8 @@ impl fmt::Display for MfaMethod { MfaMethod::Totp => "TOTP", MfaMethod::Email => "Email", MfaMethod::Oidc => "OIDC", + MfaMethod::Biometric => "Biometric", + MfaMethod::MobileApprove => "MobileApprove", } ) } @@ -892,16 +894,16 @@ impl User { username_or_email: &str, ) -> Result, SqlxError> { let maybe_user = Self::find_by_username(&mut *conn, username_or_email).await?; - match maybe_user { - Some(user) => Ok(Some(user)), - None => { - debug!( - "Failed to find user by username {username_or_email}. Attempting to find by email" - ); - Ok(Self::find_by_email(&mut *conn, username_or_email).await?) - } + if let Some(user) = maybe_user { + Ok(Some(user)) + } else { + debug!( + "Failed to find user by username {username_or_email}. Attempting to find by email" + ); + Ok(Self::find_by_email(&mut *conn, username_or_email).await?) } } + pub(crate) async fn find_many_by_emails<'e, E>( executor: E, emails: &[&str], diff --git a/crates/defguard_core/src/db/models/webhook.rs b/crates/defguard_core/src/db/models/webhook.rs index 477b2b7cc7..fb7a83dd5b 100644 --- a/crates/defguard_core/src/db/models/webhook.rs +++ b/crates/defguard_core/src/db/models/webhook.rs @@ -12,6 +12,7 @@ pub enum AppEvent { UserDeleted(String), HWKeyProvision(HWKeyUserData), } + /// User data send on HWKeyProvision AppEvent #[derive(Debug, Serialize)] pub struct HWKeyUserData { diff --git a/crates/defguard_core/src/db/models/wireguard.rs b/crates/defguard_core/src/db/models/wireguard.rs index d58d36f1bc..4c486ffa30 100644 --- a/crates/defguard_core/src/db/models/wireguard.rs +++ b/crates/defguard_core/src/db/models/wireguard.rs @@ -9,7 +9,7 @@ use base64::prelude::{BASE64_STANDARD, Engine}; use chrono::{NaiveDateTime, TimeDelta, Utc}; use ipnetwork::{IpNetwork, IpNetworkError, NetworkSize}; use model_derive::Model; -use rand_core::OsRng; +use rand::rngs::OsRng; use sqlx::{ Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, postgres::types::PgInterval, query_as, query_scalar, @@ -30,11 +30,11 @@ use super::{ }; use crate::{ AsCsv, + auth::{Claims, ClaimsType}, db::{Id, NoId}, enterprise::firewall::FirewallError, grpc::{ - GatewayState, - gateway::{Peer, send_multiple_wireguard_events}, + gateway::{Peer, send_multiple_wireguard_events, state::GatewayState}, proto::{ enterprise::firewall::FirewallConfig, proxy::LocationMfaMode as ProtoLocationMfaMode, }, @@ -213,6 +213,8 @@ pub enum WireguardNetworkError { DeviceError(#[from] DeviceError), #[error("Firewall config error: {0}")] FirewallError(#[from] FirewallError), + #[error(transparent)] + TokenError(#[from] jsonwebtoken::errors::Error), } #[derive(Debug, Error)] @@ -234,6 +236,7 @@ pub enum NetworkAddressError { } impl WireguardNetwork { + #[must_use] pub fn new( name: String, address: Vec, @@ -246,10 +249,10 @@ impl WireguardNetwork { acl_enabled: bool, acl_default_allow: bool, location_mfa_mode: LocationMfaMode, - ) -> Result { + ) -> Self { let prvkey = StaticSecret::random_from_rng(OsRng); let pubkey = PublicKey::from(&prvkey); - Ok(Self { + Self { id: NoId, name, address, @@ -266,7 +269,7 @@ impl WireguardNetwork { acl_enabled, acl_default_allow, location_mfa_mode, - }) + } } /// Try to set `address` from `&str`. @@ -509,6 +512,7 @@ impl WireguardNetwork { } /// Checks if all device addresses are contained in at least one of the network addresses + #[must_use] pub fn contains_all(&self, addresses: &[IpAddr]) -> bool { addresses .iter() @@ -516,6 +520,7 @@ impl WireguardNetwork { } /// Finds [`IpNetwork`] containing given [`IpAddr`] + #[must_use] pub fn get_containing_network(&self, addr: IpAddr) -> Option { self.address.iter().find(|net| net.contains(addr)).copied() } @@ -891,65 +896,6 @@ impl WireguardNetwork { Ok(connected_at) } - /* - /// Retrieves stats for all devices matching given `device_type`. - pub(crate) async fn device_stats_for_type( - &self, - conn: &PgPool, - device_type: DeviceType, - from: &NaiveDateTime, - aggregation: &DateTimeAggregation, - ) -> Result, SqlxError> { - let stats = query!( - "SELECT device_id \"device_id!\", device.name, device.user_id, \ - date_trunc($1, collected_at) \"collected_at!\", \ - CAST(sum(download) AS bigint) \"download!\", \ - CAST(sum(upload) AS bigint) \"upload!\" \ - FROM wireguard_peer_stats_view wpsv \ - JOIN device ON wpsv.device_id = device.id \ - WHERE device.device_type = $2 \ - AND collected_at >= $3 \ - AND network = $4 \ - GROUP BY 1, 2, 3, 4 ORDER BY 1, 4", - aggregation.fstring(), - &device_type as &DeviceType, - from, - self.id, - ) - .fetch_all(conn) - .await?; - let mut result = Vec::new(); - for stat in &stats { - let latest_stats = - WireguardPeerStats::fetch_latest(conn, stat.device_id, self.id).await?; - result.push(WireguardDeviceStatsRow { - id: stat.device_id, - user_id: stat.user_id, - name: stat.name.clone(), - wireguard_ip: latest_stats - .as_ref() - .and_then(WireguardPeerStats::trim_allowed_ips), - public_ip: latest_stats - .as_ref() - .and_then(WireguardPeerStats::endpoint_without_port), - connected_at: self.connected_at(conn, stat.device_id).await?, - // Filter stats for this device - stats: stats - .iter() - .filter(|s| s.device_id == stat.device_id) - .map(|s| WireguardDeviceTransferRow { - device_id: s.device_id, - collected_at: s.collected_at, - upload: s.upload, - download: s.download, - }) - .collect(), - }); - } - Ok(result) - } - */ - /// Retrieves stats for specified devices pub(crate) async fn device_stats( &self, @@ -1273,6 +1219,7 @@ impl WireguardNetwork { Ok(()) } + #[must_use] pub fn mfa_enabled(&self) -> bool { match self.location_mfa_mode { LocationMfaMode::Internal | LocationMfaMode::External => true, @@ -1290,8 +1237,8 @@ impl WireguardNetwork { let locations = query_as!( WireguardNetwork, "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, \ - connected_at, keepalive_interval, peer_disconnect_threshold, \ - acl_enabled, acl_default_allow, location_mfa_mode \"location_mfa_mode: LocationMfaMode\" \ + connected_at, keepalive_interval, peer_disconnect_threshold, acl_enabled, \ + acl_default_allow, location_mfa_mode \"location_mfa_mode: LocationMfaMode\" \ FROM wireguard_network WHERE location_mfa_mode = 'external'::location_mfa_mode", ) .fetch_all(executor) @@ -1299,6 +1246,21 @@ impl WireguardNetwork { Ok(locations) } + + /// Generates auth token for a VPN gateway + pub fn generate_gateway_token(&self) -> Result { + let location_id = self.id; + + let token = Claims::new( + ClaimsType::Gateway, + format!("DEFGUARD-NETWORK-{location_id}"), + location_id.to_string(), + u32::MAX.into(), + ) + .to_jwt()?; + + Ok(token) + } } // [`IpNetwork`] does not implement [`Default`] @@ -1324,7 +1286,7 @@ impl Default for WireguardNetwork { } } -#[derive(Serialize, Clone, Debug, ToSchema)] +#[derive(Serialize, ToSchema)] pub struct WireguardNetworkInfo { #[serde(flatten)] pub network: WireguardNetwork, @@ -1333,7 +1295,7 @@ pub struct WireguardNetworkInfo { pub allowed_groups: Vec, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[derive(Clone, Serialize, Deserialize, PartialEq)] pub struct WireguardStatsRow { pub collected_at: Option, pub upload: Option, @@ -2033,7 +1995,6 @@ mod test { false, LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); @@ -2165,7 +2126,6 @@ mod test { false, LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); diff --git a/crates/defguard_core/src/enterprise/activity_log_stream/activity_log_stream_manager.rs b/crates/defguard_core/src/enterprise/activity_log_stream/activity_log_stream_manager.rs index 7870c9f4f9..3faff81ab0 100644 --- a/crates/defguard_core/src/enterprise/activity_log_stream/activity_log_stream_manager.rs +++ b/crates/defguard_core/src/enterprise/activity_log_stream/activity_log_stream_manager.rs @@ -66,13 +66,12 @@ pub async fn run_activity_log_stream_manager( cancel_token.clone(), )); } - }; + } } else { error!( "Failed to deserialize config for activity log stream {0}", &activity_log_stream.name ); - continue; } } } else { @@ -87,7 +86,7 @@ pub async fn run_activity_log_stream_manager( // - streaming task terminated early loop { tokio::select! { - _ = notification.notified() => { + () = notification.notified() => { info!( "Activity log stream manager configuration refresh notification received, reloading streaming tasks." ); diff --git a/crates/defguard_core/src/enterprise/activity_log_stream/http_stream.rs b/crates/defguard_core/src/enterprise/activity_log_stream/http_stream.rs index d0b3747bb2..d835607f83 100644 --- a/crates/defguard_core/src/enterprise/activity_log_stream/http_stream.rs +++ b/crates/defguard_core/src/enterprise/activity_log_stream/http_stream.rs @@ -38,7 +38,7 @@ pub(super) async fn run_http_stream_task( }; loop { tokio::select! { - _ = cancel_token.cancelled() => { + () = cancel_token.cancelled() => { debug!("Activity log stream ({stream_name}) task received cancellation signal."); break; }, diff --git a/crates/defguard_core/src/enterprise/db/models/acl.rs b/crates/defguard_core/src/enterprise/db/models/acl.rs index 3982a802f0..60e781b47c 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -820,7 +820,7 @@ impl TryFrom for AclRule { .collect(), id: NoId, parent_id: None, - state: Default::default(), + state: RuleState::default(), name: rule.name, allow_all_users: rule.allow_all_users, deny_all_users: rule.deny_all_users, diff --git a/crates/defguard_core/src/enterprise/db/models/acl/tests.rs b/crates/defguard_core/src/enterprise/db/models/acl/tests.rs index 211c8cc769..ca828a4238 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl/tests.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl/tests.rs @@ -183,7 +183,6 @@ async fn test_rule_relations(_: PgPoolOptions, options: PgConnectOptions) { false, LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); @@ -200,7 +199,6 @@ async fn test_rule_relations(_: PgPoolOptions, options: PgConnectOptions) { false, LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); diff --git a/crates/defguard_core/src/enterprise/db/models/api_tokens.rs b/crates/defguard_core/src/enterprise/db/models/api_tokens.rs index 86f3d8d238..ef1ffebfd4 100644 --- a/crates/defguard_core/src/enterprise/db/models/api_tokens.rs +++ b/crates/defguard_core/src/enterprise/db/models/api_tokens.rs @@ -15,6 +15,7 @@ pub struct ApiToken { } impl ApiToken { + #[must_use] pub fn new(user_id: Id, created_at: NaiveDateTime, name: String, token_string: &str) -> Self { let token_hash = Self::hash_token(token_string); Self { @@ -57,8 +58,9 @@ impl ApiToken { let token_hash = ApiToken::hash_token(auth_token); let maybe_token = query_as!( Self, - "SELECT id, user_id, created_at, name, token_hash \ - FROM api_token WHERE token_hash = $1", + "SELECT at.id, user_id, created_at, name, token_hash \ + FROM api_token at JOIN \"user\" ON \"user\".id = user_id \ + WHERE token_hash = $1 AND \"user\".is_active = true", token_hash ) .fetch_optional(executor) diff --git a/crates/defguard_core/src/enterprise/db/models/openid_provider.rs b/crates/defguard_core/src/enterprise/db/models/openid_provider.rs index e8f3f2e9d1..70fa182087 100644 --- a/crates/defguard_core/src/enterprise/db/models/openid_provider.rs +++ b/crates/defguard_core/src/enterprise/db/models/openid_provider.rs @@ -115,6 +115,7 @@ pub struct OpenIdProvider { #[model(ref)] // The groups to sync from the directory, exact match pub directory_sync_group_match: Vec, + pub jumpcloud_api_key: Option, } impl OpenIdProvider { @@ -136,6 +137,7 @@ impl OpenIdProvider { okta_private_jwk: Option, okta_dirsync_client_id: Option, directory_sync_group_match: Vec, + jumpcloud_api_key: Option, ) -> Self { Self { id: NoId, @@ -155,19 +157,21 @@ impl OpenIdProvider { okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, + jumpcloud_api_key, } } - pub async fn upsert(self, pool: &PgPool) -> Result, SqlxError> { + pub(crate) async fn upsert(self, pool: &PgPool) -> Result, SqlxError> { if let Some(provider) = OpenIdProvider::::get_current(pool).await? { query!( - "UPDATE openidprovider SET name = $1, \ - base_url = $2, client_id = $3, client_secret = $4, \ - display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, \ - directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, \ + "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, \ + client_secret = $4, display_name = $5, google_service_account_key = $6, \ + google_service_account_email = $7, admin_email = $8, directory_sync_enabled = $9, \ + directory_sync_interval = $10, directory_sync_user_behavior = $11, \ directory_sync_admin_behavior = $12, directory_sync_target = $13, \ - okta_private_jwk = $14, okta_dirsync_client_id = $15, directory_sync_group_match = $16 \ - WHERE id = $17", + okta_private_jwk = $14, okta_dirsync_client_id = $15, \ + directory_sync_group_match = $16, jumpcloud_api_key = $17 \ + WHERE id = $18", self.name, self.base_url, self.client_id, @@ -184,6 +188,7 @@ impl OpenIdProvider { self.okta_private_jwk, self.okta_dirsync_client_id, &self.directory_sync_group_match, + self.jumpcloud_api_key, provider.id, ) .execute(pool) @@ -197,7 +202,10 @@ impl OpenIdProvider { } impl OpenIdProvider { - pub async fn find_by_name<'e, E>(executor: E, name: &str) -> Result, SqlxError> + pub(crate) async fn find_by_name<'e, E>( + executor: E, + name: &str, + ) -> Result, SqlxError> where E: PgExecutor<'e>, { @@ -208,7 +216,7 @@ impl OpenIdProvider { directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ directory_sync_target \"directory_sync_target: DirectorySyncTarget\", \ - okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match \ + okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key \ FROM openidprovider WHERE name = $1", name ) @@ -216,7 +224,7 @@ impl OpenIdProvider { .await } - pub async fn get_current<'e, E>(executor: E) -> Result, SqlxError> + pub(crate) async fn get_current<'e, E>(executor: E) -> Result, SqlxError> where E: PgExecutor<'e>, { @@ -227,7 +235,7 @@ impl OpenIdProvider { directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ directory_sync_target \"directory_sync_target: DirectorySyncTarget\", \ - okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match \ + okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key \ FROM openidprovider LIMIT 1" ) .fetch_optional(executor) diff --git a/crates/defguard_core/src/enterprise/db/models/snat.rs b/crates/defguard_core/src/enterprise/db/models/snat.rs index 19448eed7b..127da55bf9 100644 --- a/crates/defguard_core/src/enterprise/db/models/snat.rs +++ b/crates/defguard_core/src/enterprise/db/models/snat.rs @@ -1,14 +1,15 @@ use std::net::IpAddr; -use crate::{ - db::{Id, NoId}, - enterprise::snat::error::UserSnatBindingError, -}; use model_derive::Model; use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, query_as}; use utoipa::ToSchema; +use crate::{ + db::{Id, NoId}, + enterprise::snat::error::UserSnatBindingError, +}; + #[derive(Clone, Debug, Deserialize, Model, Serialize, ToSchema)] #[table(user_snat_binding)] pub struct UserSnatBinding { @@ -21,6 +22,7 @@ pub struct UserSnatBinding { } impl UserSnatBinding { + #[must_use] pub fn new(user_id: Id, location_id: Id, public_ip: IpAddr) -> Self { Self { id: NoId, diff --git a/crates/defguard_core/src/enterprise/directory_sync/google.rs b/crates/defguard_core/src/enterprise/directory_sync/google.rs index b7f45601a1..4f7a5139fa 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/google.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/google.rs @@ -105,6 +105,7 @@ impl From for DirectoryUser { Self { email: val.primary_email, active: !val.suspended, + id: None, } } } @@ -425,17 +426,18 @@ impl DirectorySync for GoogleDirectorySync { async fn get_user_groups( &self, - user_id: &str, + user_email: &str, ) -> Result, DirectorySyncError> { - debug!("Getting groups of user {user_id}"); - let response = self.query_user_groups(user_id).await?; - debug!("Got groups response for user {user_id}"); + debug!("Getting groups of user {user_email}"); + let response = self.query_user_groups(user_email).await?; + debug!("Got groups response for user {user_email}"); Ok(response.groups) } async fn get_group_members( &self, group: &DirectoryGroup, + _all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError> { debug!("Getting group members of group {}", group.name); let response = self.query_group_members(group).await?; diff --git a/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs new file mode 100644 index 0000000000..aad95011ae --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/jumpcloud.rs @@ -0,0 +1,823 @@ +use std::collections::HashMap; + +use tokio::time::sleep; + +use super::{ + DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, REQUEST_PAGINATION_SLOWDOWN, + parse_response, +}; + +const GROUPS_URL: &str = "https://console.jumpcloud.com/api/v2/usergroups"; +const ALL_USERS_URL: &str = "https://console.jumpcloud.com/api/systemusers"; +const USER_GROUPS_URL: &str = "https://console.jumpcloud.com/api/v2/users//memberof"; +const USER_GROUP_MEMBERS_URL: &str = + "https://console.jumpcloud.com/api/v2/usergroups//members"; +const MAX_REQUESTS: usize = 50; +const MAX_RESULTS: usize = 100; +const API_KEY_HEADER: &str = "x-api-key"; + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +enum UserState { + Staged, + Activated, + Suspended, +} + +#[derive(Debug, Deserialize)] +struct User { + email: String, + activated: bool, + account_locked: bool, + id: String, + state: UserState, +} + +impl From for DirectoryUser { + fn from(user: User) -> Self { + DirectoryUser { + email: user.email, + active: user.activated && !user.account_locked && user.state == UserState::Activated, + id: Some(user.id), + } + } +} + +#[derive(Debug, Deserialize)] +struct UsersResponse { + results: Vec, + #[serde(rename = "totalCount")] + total_count: usize, +} + +impl From for Vec { + fn from(response: UsersResponse) -> Self { + response.results.into_iter().map(Into::into).collect() + } +} + +#[derive(Debug, Deserialize)] +struct GroupsResponse { + results: Vec, +} + +impl From for Vec { + fn from(response: GroupsResponse) -> Self { + response.results + } +} + +#[derive(Debug, Deserialize)] +struct LdapGroup { + name: String, +} + +#[derive(Debug, Deserialize)] +struct CompiledAttributes { + #[serde(rename = "ldapGroups")] + ldap_groups: Vec, +} + +#[derive(Debug, Deserialize)] +struct UserGroup { + id: String, + #[serde(rename = "compiledAttributes")] + compiled_attributes: CompiledAttributes, +} + +impl From for DirectoryGroup { + fn from(group: UserGroup) -> Self { + let name = group.compiled_attributes.ldap_groups.first().map_or_else( + || { + debug!( + "Group {} has no LDAP groups, using ID as name fallback", + group.id + ); + group.id.clone() + }, + |g| g.name.clone(), + ); + DirectoryGroup { id: group.id, name } + } +} + +#[derive(Debug, Deserialize)] +struct GroupMember { + id: String, + #[serde(rename = "type")] + member_type: String, +} + +#[derive(Debug, Deserialize)] +struct GroupMemberThing { + to: GroupMember, +} + +pub(crate) struct JumpCloudDirectorySync { + api_key: String, +} + +impl JumpCloudDirectorySync { + #[must_use] + pub fn new(api_key: String) -> Self { + debug!( + "Initializing JumpCloud directory sync with API key length: {}", + api_key.len() + ); + Self { api_key } + } + + async fn query_group_members( + &self, + group: &DirectoryGroup, + ) -> Result, DirectorySyncError> { + debug!( + "Starting to query members for group: {} (ID: {})", + group.name, group.id + ); + let client = reqwest::Client::new(); + let url = USER_GROUP_MEMBERS_URL.replace("", &group.id); + let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + + debug!("Requesting group members from URL: {url}"); + debug!("Initial query parameters: {query:?}"); + + let response = client + .get(&url) + .header(API_KEY_HEADER, &self.api_key) + .query(&query) + .send() + .await?; + + debug!( + "Initial response status for group {}: {}", + group.id, + response.status() + ); + let mut all_members_response: Vec = parse_response( + response, + "Failed to query group members from JumpCloud API.", + ) + .await?; + + debug!( + "Initial batch fetched {} members for group {}", + all_members_response.len(), + group.id + ); + + for i in 1..MAX_REQUESTS { + let skip_value = i * MAX_RESULTS; + query.insert("skip", skip_value.to_string()); + + debug!( + "Requesting page {} (skip: {skip_value}) for group {} members", + i + 1, + group.id + ); + + let response = client + .get(&url) + .header(API_KEY_HEADER, &self.api_key) + .query(&query) + .send() + .await?; + + debug!( + "Page {} response status for group {}: {}", + i + 1, + group.id, + response.status() + ); + let members_response: Vec = parse_response( + response, + "Failed to query group members from JumpCloud API.", + ) + .await?; + + debug!( + "Page {} returned {} members for group {}", + i + 1, + members_response.len(), + group.id + ); + + if members_response.is_empty() { + debug!( + "No more members found for group {}, stopping pagination", + group.id + ); + break; + } + all_members_response.extend(members_response); + debug!( + "Total members accumulated so far for group {}: {}", + group.id, + all_members_response.len() + ); + + sleep(REQUEST_PAGINATION_SLOWDOWN).await; + } + + debug!( + "Total members fetched for group {}: {}", + group.id, + all_members_response.len() + ); + Ok(all_members_response) + } + + async fn query_groups(&self) -> Result, DirectorySyncError> { + debug!("Starting to query groups from JumpCloud API"); + let client = reqwest::Client::new(); + + let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + debug!("Initial query parameters: {query:?}"); + + debug!("Sending initial request to: {GROUPS_URL}"); + let response = client + .get(GROUPS_URL) + .header(API_KEY_HEADER, &self.api_key) + .query(&query) + .send() + .await?; + + debug!("Initial response status: {}", response.status()); + let mut all_groups_response: Vec = + parse_response(response, "Failed to query groups from JumpCloud API.").await?; + + debug!("Initial batch fetched {} groups", all_groups_response.len()); + + for i in 1..MAX_REQUESTS { + let skip_value = i * MAX_RESULTS; + query.insert("skip", skip_value.to_string()); + + debug!( + "Requesting page {} (skip: {skip_value}) from JumpCloud API", + i + 1 + ); + + let response = client + .get(GROUPS_URL) + .header(API_KEY_HEADER, &self.api_key) + .query(&query) + .send() + .await?; + + debug!("Page {} response status: {}", i + 1, response.status()); + let groups_response: Vec = + parse_response(response, "Failed to query groups from JumpCloud API.").await?; + + debug!("Page {} returned {} groups", i + 1, groups_response.len()); + + if groups_response.is_empty() { + debug!("No more groups found, stopping pagination"); + break; + } + all_groups_response.extend(groups_response); + debug!( + "Total groups accumulated so far: {}", + all_groups_response.len() + ); + + sleep(REQUEST_PAGINATION_SLOWDOWN).await; + } + + debug!("Total groups fetched: {}", all_groups_response.len()); + Ok(all_groups_response) + } + + async fn query_all_users(&self) -> Result { + debug!("Starting to query all users from JumpCloud API"); + let client = reqwest::Client::new(); + + let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + debug!("Initial query parameters for users: {query:?}"); + debug!("Sending initial request to: {ALL_USERS_URL}"); + + let response = client + .get(ALL_USERS_URL) + .header(API_KEY_HEADER, &self.api_key) + .query(&query) + .send() + .await?; + + debug!("Initial users response status: {}", response.status()); + let mut all_users_response: UsersResponse = + parse_response(response, "Failed to query users from JumpCloud API.").await?; + + debug!( + "Initial batch fetched {} users (total_count: {})", + all_users_response.results.len(), + all_users_response.total_count + ); + + for i in 1..MAX_REQUESTS { + let skip_value = i * MAX_RESULTS; + query.insert("skip", skip_value.to_string()); + + debug!("Requesting page {} (skip: {skip_value}) for users", i + 1); + + let response = client + .get(ALL_USERS_URL) + .header(API_KEY_HEADER, &self.api_key) + .query(&query) + .send() + .await?; + + debug!( + "Page {} response status for users: {}", + i + 1, + response.status() + ); + let users_response: UsersResponse = + parse_response(response, "Failed to query users from JumpCloud API.").await?; + + debug!( + "Page {} returned {} users", + i + 1, + users_response.results.len() + ); + + if users_response.results.is_empty() { + debug!("No more users found, stopping pagination"); + break; + } + all_users_response.results.extend(users_response.results); + debug!( + "Total users accumulated so far: {}", + all_users_response.results.len() + ); + + sleep(REQUEST_PAGINATION_SLOWDOWN).await; + } + + debug!( + "Total users fetched: {} (final total_count: {})", + all_users_response.results.len(), + all_users_response.total_count + ); + Ok(all_users_response) + } + + async fn query_user_groups(&self, user_id: &str) -> Result, DirectorySyncError> { + debug!("Starting to query groups for user: {user_id}"); + let client = reqwest::Client::new(); + let url = USER_GROUPS_URL.replace("", user_id); + + let mut query = HashMap::from([("limit", MAX_RESULTS.to_string())]); + debug!("Requesting user groups from URL: {url}"); + debug!("Initial query parameters for user groups: {query:?}"); + + let response = client + .get(&url) + .header(API_KEY_HEADER, &self.api_key) + .query(&query) + .send() + .await?; + + debug!( + "Initial response status for user {user_id} groups: {}", + response.status() + ); + let mut all_groups_response: Vec = + parse_response(response, "Failed to query user groups from JumpCloud API.").await?; + + debug!( + "Initial batch fetched {} groups for user {user_id}", + all_groups_response.len() + ); + + for i in 1..MAX_REQUESTS { + let skip_value = i * MAX_RESULTS; + query.insert("skip", skip_value.to_string()); + + debug!( + "Requesting page {} (skip: {}) for user {user_id} groups", + i + 1, + skip_value, + ); + + let response = client + .get(&url) + .header(API_KEY_HEADER, &self.api_key) + .query(&query) + .send() + .await?; + + debug!( + "Page {} response status for user {user_id} groups: {}", + i + 1, + response.status() + ); + let groups_response: Vec = + parse_response(response, "Failed to query user groups from JumpCloud API.").await?; + + debug!( + "Page {} returned {} groups for user {user_id}", + i + 1, + groups_response.len(), + ); + + if groups_response.is_empty() { + debug!("No more groups found for user {user_id}, stopping pagination"); + break; + } + all_groups_response.extend(groups_response); + debug!( + "Total groups accumulated so far for user {user_id}: {}", + all_groups_response.len() + ); + + sleep(REQUEST_PAGINATION_SLOWDOWN).await; + } + + debug!( + "Total groups fetched for user {user_id}: {}", + all_groups_response.len() + ); + Ok(all_groups_response) + } + + async fn query_test_connection(&self) -> Result<(), DirectorySyncError> { + debug!("Testing connection to JumpCloud API"); + let client = reqwest::Client::new(); + debug!("Sending test request to: {ALL_USERS_URL}"); + + let response = client + .get(ALL_USERS_URL) + .header(API_KEY_HEADER, &self.api_key) + .send() + .await?; + + debug!("Test connection response status: {}", response.status()); + let _: UsersResponse = + parse_response(response, "Failed to test connection to JumpCloud API.").await?; + debug!("Test connection successful - API key is valid and endpoint is accessible"); + Ok(()) + } + + async fn get_user_by_email( + &self, + email: &str, + ) -> Result, DirectorySyncError> { + debug!("Starting search for user by email: {email}"); + let client = reqwest::Client::new(); + + let filter = format!("email:$eq:{email}"); + + debug!("Querying JumpCloud for user with email: {email}"); + debug!("Using filter: {filter}"); + debug!("Sending request to: {ALL_USERS_URL}"); + + let response = client + .get(ALL_USERS_URL) + .header(API_KEY_HEADER, &self.api_key) + .query(&[("filter", &filter)]) + .send() + .await?; + + debug!("User search response status: {}", response.status()); + + if response.status().is_success() { + let mut users: UsersResponse = + parse_response(response, "Failed to query user by email.").await?; + + debug!( + "User search returned {} users (total_count: {})", + users.results.len(), + users.total_count + ); + + if users.total_count > 1 { + warn!( + "Multiple users found with email: {} (count: {})", + email, users.total_count + ); + return Err(DirectorySyncError::MultipleUsersFound(format!( + "Multiple users found with email: {email}." + ))); + } + + if let Some(user) = users.results.pop() { + debug!( + "Found user: {} (ID: {}, activated: {}, locked: {}, state: {:?})", + user.email, user.id, user.activated, user.account_locked, user.state + ); + Ok(Some(user.into())) + } else { + debug!("No user found with email: {}", email); + Ok(None) + } + } else { + error!( + "Failed to query user by email: {}. Status: {}", + email, + response.status() + ); + Err(DirectorySyncError::RequestError(format!( + "Failed to query user by email: {}. Status: {}. Details: {}", + email, + response.status(), + response + .text() + .await + .unwrap_or_else(|_| "No details".to_string()) + ))) + } + } +} + +impl DirectorySync for JumpCloudDirectorySync { + async fn get_groups(&self) -> Result, DirectorySyncError> { + debug!("Getting all groups"); + let response = self.query_groups().await?; + debug!("Got all groups response"); + Ok(response) + } + + async fn get_user_groups( + &self, + user_email: &str, + ) -> Result, DirectorySyncError> { + debug!("Getting groups of user {user_email}"); + if let Some(user) = self.get_user_by_email(user_email).await? { + if let Some(user_id) = user.id { + let response = self.query_user_groups(&user_id).await?; + debug!("Got groups response for user {user_id}"); + return Ok(response.into_iter().map(Into::into).collect()); + } + } + + debug!("No user found with email {user_email}, returning an error."); + Err(DirectorySyncError::UserNotFound(user_email.to_string())) + } + + async fn get_group_members( + &self, + group: &DirectoryGroup, + all_users_helper: Option<&[DirectoryUser]>, + ) -> Result, DirectorySyncError> { + debug!("Getting group members of group {}", group.name); + + let users: Vec; + + // extract all_users_helper, if its empty, return an error + let all_users = if let Some(users) = all_users_helper { + debug!("Using provided all users helper"); + users + } else { + debug!("No all users helper provided, forcing a query for all users as a fallback."); + users = self.query_all_users().await?.into(); + &users + }; + + let member_response = self + .query_group_members(group) + .await? + .into_iter() + .filter(|m| m.to.member_type == "user") + .collect::>(); + + let mut members = Vec::new(); + for member in member_response { + if let Some(user) = all_users + .iter() + .find(|u| u.id.as_deref() == Some(&member.to.id) && u.active) + { + members.push(user.email.clone()); + } else { + debug!( + "Skipping member with ID {} in group {} as they are not found in all users", + member.to.id, group.name + ); + } + } + debug!( + "Got group members response for group {}. Extracting their email addresses...", + group.name + ); + Ok(members) + } + + async fn prepare(&mut self) -> Result<(), DirectorySyncError> { + debug!("JumpCloud does not require any preparation steps, skipping."); + Ok(()) + } + + async fn get_all_users(&self) -> Result, DirectorySyncError> { + debug!("Getting all users"); + let response = self.query_all_users().await?; + debug!("Got all users response"); + Ok(response.into()) + } + + async fn test_connection(&self) -> Result<(), DirectorySyncError> { + debug!("Testing connection to JumpCloud API."); + self.query_test_connection().await?; + info!("Successfully tested connection to JumpCloud API, connection is working."); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_user_to_directory_user_conversions() { + // Test active user (activated=true, account_locked=false, state=ACTIVATED) + let active_user = User { + email: "active@example.com".to_string(), + activated: true, + account_locked: false, + id: "user123".to_string(), + state: UserState::Activated, + }; + let active_directory_user: DirectoryUser = active_user.into(); + assert_eq!(active_directory_user.email, "active@example.com"); + assert!(active_directory_user.active); + assert_eq!(active_directory_user.id, Some("user123".to_string())); + + // Test inactive user (activated=false) + let inactive_user = User { + email: "inactive@example.com".to_string(), + activated: false, + account_locked: false, + id: "user456".to_string(), + state: UserState::Activated, + }; + let inactive_directory_user: DirectoryUser = inactive_user.into(); + assert_eq!(inactive_directory_user.email, "inactive@example.com"); + assert!(!inactive_directory_user.active); + assert_eq!(inactive_directory_user.id, Some("user456".to_string())); + + // Test locked user (account_locked=true) + let locked_user = User { + email: "locked@example.com".to_string(), + activated: true, + account_locked: true, + id: "user789".to_string(), + state: UserState::Activated, + }; + let locked_directory_user: DirectoryUser = locked_user.into(); + assert_eq!(locked_directory_user.email, "locked@example.com"); + assert!(!locked_directory_user.active); + assert_eq!(locked_directory_user.id, Some("user789".to_string())); + + // Test suspended user (state=SUSPENDED) + let suspended_user = User { + email: "suspended@example.com".to_string(), + activated: true, + account_locked: false, + id: "user999".to_string(), + state: UserState::Suspended, + }; + let suspended_directory_user: DirectoryUser = suspended_user.into(); + assert_eq!(suspended_directory_user.email, "suspended@example.com"); + assert!(!suspended_directory_user.active); + assert_eq!(suspended_directory_user.id, Some("user999".to_string())); + + // Test staged user (state=STAGED) + let staged_user = User { + email: "staged@example.com".to_string(), + activated: true, + account_locked: false, + id: "user888".to_string(), + state: UserState::Staged, + }; + let staged_directory_user: DirectoryUser = staged_user.into(); + assert_eq!(staged_directory_user.email, "staged@example.com"); + assert!(!staged_directory_user.active); + assert_eq!(staged_directory_user.id, Some("user888".to_string())); + + // Test both inactive and locked user + let both_user = User { + email: "both@example.com".to_string(), + activated: false, + account_locked: true, + id: "user000".to_string(), + state: UserState::Activated, + }; + let both_directory_user: DirectoryUser = both_user.into(); + assert_eq!(both_directory_user.email, "both@example.com"); + assert!(!both_directory_user.active); + assert_eq!(both_directory_user.id, Some("user000".to_string())); + } + + #[test] + fn test_user_group_to_directory_group_conversions() { + // Test group with LDAP groups (uses first LDAP group name) + let group_with_ldap = UserGroup { + id: "group123".to_string(), + compiled_attributes: CompiledAttributes { + ldap_groups: vec![ + LdapGroup { + name: "LDAP Group Name".to_string(), + }, + LdapGroup { + name: "Second LDAP Group".to_string(), + }, + ], + }, + }; + let directory_group_with_ldap: DirectoryGroup = group_with_ldap.into(); + assert_eq!(directory_group_with_ldap.id, "group123"); + assert_eq!(directory_group_with_ldap.name, "LDAP Group Name"); + + // Test group with empty LDAP groups (falls back to group ID) + let group_empty_ldap = UserGroup { + id: "group789".to_string(), + compiled_attributes: CompiledAttributes { + ldap_groups: vec![], + }, + }; + let directory_group_empty_ldap: DirectoryGroup = group_empty_ldap.into(); + assert_eq!(directory_group_empty_ldap.id, "group789"); + assert_eq!(directory_group_empty_ldap.name, "group789"); + } + + #[test] + fn test_response_collection_conversions() { + // Test empty UsersResponse conversion + let empty_users_response = UsersResponse { + results: vec![], + total_count: 0, + }; + let empty_directory_users: Vec = empty_users_response.into(); + assert!(empty_directory_users.is_empty()); + + // Test single user UsersResponse conversion + let single_users_response = UsersResponse { + results: vec![User { + email: "single@example.com".to_string(), + activated: true, + account_locked: false, + id: "single123".to_string(), + state: UserState::Activated, + }], + total_count: 1, + }; + let single_directory_users: Vec = single_users_response.into(); + assert_eq!(single_directory_users.len(), 1); + assert_eq!(single_directory_users[0].email, "single@example.com"); + assert!(single_directory_users[0].active); + assert_eq!(single_directory_users[0].id, Some("single123".to_string())); + + // Test multiple users with mixed states + let multiple_users_response = UsersResponse { + results: vec![ + User { + email: "user1@example.com".to_string(), + activated: true, + account_locked: false, + id: "user1".to_string(), + state: UserState::Activated, + }, + User { + email: "user2@example.com".to_string(), + activated: false, + account_locked: false, + id: "user2".to_string(), + state: UserState::Activated, + }, + User { + email: "user3@example.com".to_string(), + activated: true, + account_locked: true, + id: "user3".to_string(), + state: UserState::Activated, + }, + ], + total_count: 3, + }; + let multiple_directory_users: Vec = multiple_users_response.into(); + assert_eq!(multiple_directory_users.len(), 3); + assert_eq!(multiple_directory_users[0].email, "user1@example.com"); + assert!(multiple_directory_users[0].active); + assert_eq!(multiple_directory_users[1].email, "user2@example.com"); + assert!(!multiple_directory_users[1].active); + assert_eq!(multiple_directory_users[2].email, "user3@example.com"); + assert!(!multiple_directory_users[2].active); + + // Test GroupsResponse conversion + let groups_response = GroupsResponse { + results: vec![ + DirectoryGroup { + id: "group1".to_string(), + name: "Group 1".to_string(), + }, + DirectoryGroup { + id: "group2".to_string(), + name: "Group 2".to_string(), + }, + ], + }; + let directory_groups: Vec = groups_response.into(); + assert_eq!(directory_groups.len(), 2); + assert_eq!(directory_groups[0].id, "group1"); + assert_eq!(directory_groups[0].name, "Group 1"); + assert_eq!(directory_groups[1].id, "group2"); + assert_eq!(directory_groups[1].name, "Group 2"); + } +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs b/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs index 0f1044bc37..48ebef13a5 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs @@ -59,9 +59,10 @@ impl From for Vec { response .value .into_iter() - .filter_map(|group| match group.display_name { - Some(name) => Some(DirectoryGroup { id: group.id, name }), - None => { + .filter_map(|group| { + if let Some(name) = group.display_name { + Some(DirectoryGroup { id: group.id, name }) + } else { warn!( "Group with ID {} doesn't have a display name set, skipping it.", group.id @@ -125,10 +126,10 @@ impl From for Vec { .into_iter() .filter_map(|user| { if let Some(email) = user.mail { - Some(DirectoryUser { email, active: user.account_enabled }) + Some(DirectoryUser { email, active: user.account_enabled, id: None }) } else if let Some(email) = user.other_mails.into_iter().next() { warn!("User {} doesn't have a primary email address set, his first additional email address will be used: {email}", user.display_name); - Some(DirectoryUser { email, active: user.account_enabled }) + Some(DirectoryUser { email, active: user.account_enabled, id: None }) } else { warn!("User {} doesn't have any email address and will be skipped in synchronization.", user.display_name); None @@ -177,7 +178,7 @@ impl MicrosoftDirectorySync { self.url )))?; debug!("Tenant ID extracted successfully: {tenant_id}",); - Ok(tenant_id.to_string()) + Ok((*tenant_id).to_string()) } async fn refresh_access_token(&mut self) -> Result<(), DirectorySyncError> { @@ -252,7 +253,30 @@ impl MicrosoftDirectorySync { let mut combined_response = GroupsResponse::default(); let mut url = GROUPS_URL.to_string(); - if !self.group_filter.is_empty() { + if self.group_filter.is_empty() { + debug!("No group filter defined, all groups will be synced."); + let params = vec![("$top", MAX_RESULTS)]; + let mut query = Some(params.as_slice()); + + for _ in 0..MAX_REQUESTS { + let response = make_get_request(&url, access_token, query).await?; + let response: GroupsResponse = + parse_response(response, "Failed to query Microsoft groups.").await?; + combined_response.value.extend(response.value); + + if let Some(next_page) = response.next_page { + url = next_page; + // Set `query` to `None` as the next page URL already contains query parameters from the preceding request. + query = None; + debug!("Found next page of results, querying it: {url}"); + } else { + debug!("No more pages of results found, finishing query."); + break; + } + + sleep(REQUEST_PAGINATION_SLOWDOWN).await; + } + } else { info!( "Applying defined group filter to user group query, only the following groups will be synced: {:?}", self.group_filter @@ -261,7 +285,7 @@ impl MicrosoftDirectorySync { let groups = self .group_filter .iter() - .map(|group| group.replace("'", "''")) + .map(|group| group.replace('\'', "''")) .collect::>(); // Microsoft has a limit of about 15 OR conditions per request, so batch it first. @@ -284,29 +308,6 @@ impl MicrosoftDirectorySync { parse_response(response, "Failed to query Microsoft groups.").await?; combined_response.value.extend(response.value); - sleep(REQUEST_PAGINATION_SLOWDOWN).await; - } - } else { - debug!("No group filter defined, all groups will be synced."); - let params = vec![("$top", MAX_RESULTS)]; - let mut query = Some(params.as_slice()); - - for _ in 0..MAX_REQUESTS { - let response = make_get_request(&url, access_token, query).await?; - let response: GroupsResponse = - parse_response(response, "Failed to query Microsoft groups.").await?; - combined_response.value.extend(response.value); - - if let Some(next_page) = response.next_page { - url = next_page; - // Set `query` to `None` as the next page URL already contains query parameters from the preceding request. - query = None; - debug!("Found next page of results, querying it: {url}"); - } else { - debug!("No more pages of results found, finishing query."); - break; - } - sleep(REQUEST_PAGINATION_SLOWDOWN).await; } } @@ -499,10 +500,10 @@ impl DirectorySync for MicrosoftDirectorySync { async fn get_user_groups( &self, - user_id: &str, + user_email: &str, ) -> Result, DirectorySyncError> { - debug!("Querying groups of user: {user_id}"); - let groups = self.query_user_groups(user_id).await?; + debug!("Querying groups of user: {user_email}"); + let groups = self.query_user_groups(user_email).await?; debug!("User groups queried successfully."); Ok(groups.into()) } @@ -510,6 +511,7 @@ impl DirectorySync for MicrosoftDirectorySync { async fn get_group_members( &self, group: &DirectoryGroup, + _all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError> { debug!("Querying members of group: {}", group.name); let members = self.query_group_members(group).await?; diff --git a/crates/defguard_core/src/enterprise/directory_sync/mod.rs b/crates/defguard_core/src/enterprise/directory_sync/mod.rs index fef3db04c3..78e25f8a96 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/mod.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/mod.rs @@ -79,10 +79,13 @@ impl From for DirectorySyncError { } pub mod google; +pub mod jumpcloud; pub mod microsoft; pub mod okta; #[cfg(test)] pub mod testprovider; +#[cfg(test)] +pub mod tests; #[derive(Debug, Serialize, Deserialize)] pub struct DirectoryGroup { @@ -92,6 +95,7 @@ pub struct DirectoryGroup { #[derive(Debug, Serialize, Deserialize)] pub struct DirectoryUser { + pub id: Option, pub email: String, // Users may be disabled/suspended in the directory pub active: bool, @@ -106,13 +110,16 @@ trait DirectorySync { /// Get all groups a user is a member of async fn get_user_groups( &self, - user_id: &str, + user_email: &str, ) -> Result, DirectorySyncError>; - /// Get all members of a group + /// Get all members of a group, returns a list of user emails async fn get_group_members( &self, group: &DirectoryGroup, + // Some providers (JumpCloud) doesn't return emails of group members, just ids. + // In such cases, we can use the list of all users in the directory to map ids to emails. + all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError>; /// Prepare the directory sync client, e.g. get an access token @@ -155,18 +162,20 @@ macro_rules! dirsync_clients { } } - async fn get_user_groups(&self, user_id: &str) -> Result, DirectorySyncError> { + async fn get_user_groups(&self, user_email: &str) -> Result, DirectorySyncError> { match self { $( - DirectorySyncClient::$variant(client) => client.get_user_groups(user_id).await, + DirectorySyncClient::$variant(client) => client.get_user_groups(user_email).await, )* } } - async fn get_group_members(&self, group: &DirectoryGroup) -> Result, DirectorySyncError> { + async fn get_group_members(&self, group: &DirectoryGroup, + all_users_helper: Option<&[DirectoryUser]>, + ) -> Result, DirectorySyncError> { match self { $( - DirectorySyncClient::$variant(client) => client.get_group_members(group).await, + DirectorySyncClient::$variant(client) => client.get_group_members(group, all_users_helper).await, )* } } @@ -199,10 +208,10 @@ macro_rules! dirsync_clients { } #[cfg(test)] -dirsync_clients!(Google, Microsoft, Okta, TestProvider); +dirsync_clients!(Google, Microsoft, Okta, TestProvider, JumpCloud); #[cfg(not(test))] -dirsync_clients!(Google, Microsoft, Okta); +dirsync_clients!(Google, Microsoft, Okta, JumpCloud); impl DirectorySyncClient { /// Builds the current directory sync client based on the current provider settings (provider name), if possible. @@ -260,6 +269,19 @@ impl DirectorySyncClient { )) } } + "JumpCloud" => { + debug!("JumpCloud directory sync provider selected"); + if let Some(key) = provider_settings.jumpcloud_api_key.as_ref() { + debug!( + "JumpCloud directory has all the configuration needed, proceeding with creating the sync client" + ); + let client = jumpcloud::JumpCloudDirectorySync::new(key.clone()); + debug!("JumpCloud directory sync client created"); + Ok(Self::JumpCloud(client)) + } else { + Err(DirectorySyncError::NotConfigured) + } + } #[cfg(test)] "Test" => Ok(Self::TestProvider(testprovider::TestProviderDirectorySync)), _ => Err(DirectorySyncError::UnsupportedProvider( @@ -431,6 +453,7 @@ async fn sync_all_users_groups( directory_sync: &T, pool: &PgPool, wg_tx: &Sender, + all_users: Option<&[DirectoryUser]>, ) -> Result<(), DirectorySyncError> { info!("Syncing all users' groups with the directory, this may take a while..."); let directory_groups = directory_sync.get_groups().await?; @@ -443,7 +466,7 @@ async fn sync_all_users_groups( "Beggining a construction of user-group mapping which will be applied later to Defguard" ); for group in &directory_groups { - match directory_sync.get_group_members(group).await { + match directory_sync.get_group_members(group, all_users).await { Ok(members) => { debug!( "Group {} has {} members in the directory, adding them to the user-group mapping", @@ -533,7 +556,11 @@ async fn sync_all_users_groups( } transaction.commit().await?; - ldap_update_users_state(affected_users.iter_mut().collect::>(), pool).await; + Box::pin(ldap_update_users_state( + affected_users.iter_mut().collect::>(), + pool, + )) + .await; info!("Syncing all users' groups done."); Ok(()) } @@ -555,14 +582,13 @@ fn is_directory_sync_enabled(provider: Option<&OpenIdProvider>) -> bool { ) } -async fn sync_all_users_state( - directory_sync: &T, +async fn sync_all_users_state( pool: &PgPool, wg_tx: &Sender, + all_users: &[DirectoryUser], ) -> Result<(), DirectorySyncError> { info!("Syncing all users' state with the directory, this may take a while..."); let mut transaction = pool.begin().await?; - let all_users = directory_sync.get_all_users().await?; let settings = OpenIdProvider::get_current(pool) .await? .ok_or(DirectorySyncError::NotConfigured)?; @@ -764,7 +790,11 @@ async fn sync_all_users_state( transaction.commit().await?; ldap_delete_users(deleted_users.iter().collect::>(), pool).await; - ldap_update_users_state(modified_users.iter_mut().collect::>(), pool).await; + Box::pin(ldap_update_users_state( + modified_users.iter_mut().collect::>(), + pool, + )) + .await; info!("Syncing all users' state with the directory done"); @@ -815,17 +845,38 @@ pub(crate) async fn do_directory_sync( // Same goes for Etags, those could be used to reduce the amount of data transferred. Some way // of preserving them should be implemented. dir_sync.prepare().await?; + + // This is an optimization, both sync_all_users_state and sync_all_users_groups depend on it so we might + // as well get all users once and pass it to both functions. + let mut all_users = None; + if matches!( sync_target, DirectorySyncTarget::All | DirectorySyncTarget::Users ) { - sync_all_users_state(&dir_sync, pool, wireguard_tx).await?; + let users = dir_sync.get_all_users().await?; + sync_all_users_state(pool, wireguard_tx, &users).await?; + all_users = Some(users); } if matches!( sync_target, DirectorySyncTarget::All | DirectorySyncTarget::Groups ) { - sync_all_users_groups(&dir_sync, pool, wireguard_tx).await?; + // Sometimes we don't even need to query all users, this is an optimization to reduce the amount of data transferred. + let users_to_pass = match dir_sync { + DirectorySyncClient::JumpCloud(_) => { + if all_users.is_none() { + // JumpCloud doesn't return emails of group members, so we need to pass all users + // to the get_user_groups method to map ids to emails. + Some(dir_sync.get_all_users().await?) + } else { + all_users + } + } + _ => None, // No need to pass all users for other providers, for the time being. + }; + sync_all_users_groups(&dir_sync, pool, wireguard_tx, users_to_pass.as_deref()) + .await?; } } Err(err) => { @@ -895,755 +946,3 @@ async fn make_get_request( ))), } } - -#[cfg(test)] -mod test { - use std::str::FromStr; - - use ipnetwork::IpNetwork; - use secrecy::ExposeSecret; - use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; - use tokio::sync::broadcast; - - use super::*; - use crate::{ - SERVER_CONFIG, - config::DefGuardConfig, - db::{ - Device, Session, SessionState, Settings, WireguardNetwork, - models::{ - device::DeviceType, settings::initialize_current_settings, - wireguard::LocationMfaMode, - }, - setup_pool, - }, - enterprise::db::models::openid_provider::DirectorySyncTarget, - }; - - async fn get_test_network(pool: &PgPool) -> WireguardNetwork { - WireguardNetwork::find_by_name(pool, "test") - .await - .unwrap() - .unwrap() - .pop() - .unwrap() - } - - async fn make_test_provider( - pool: &PgPool, - user_behavior: DirectorySyncUserBehavior, - admin_behavior: DirectorySyncUserBehavior, - target: DirectorySyncTarget, - ) -> OpenIdProvider { - Settings::init_defaults(pool).await.unwrap(); - initialize_current_settings(pool).await.unwrap(); - - let current = OpenIdProvider::get_current(pool).await.unwrap(); - - if let Some(provider) = current { - provider.delete(pool).await.unwrap(); - } - - WireguardNetwork::new( - "test".to_string(), - vec![IpNetwork::from_str("10.10.10.1/24").unwrap()], - 1234, - "123.123.123.123".to_string(), - None, - vec![], - 32, - 32, - false, - false, - LocationMfaMode::Disabled, - ) - .unwrap() - .save(pool) - .await - .unwrap(); - - OpenIdProvider::new( - "Test".to_string(), - "base_url".to_string(), - "client_id".to_string(), - "client_secret".to_string(), - Some("display_name".to_string()), - Some("google_service_account_key".to_string()), - Some("google_service_account_email".to_string()), - Some("admin_email".to_string()), - true, - 60, - user_behavior, - admin_behavior, - target, - None, - None, - vec![], - ) - .save(pool) - .await - .unwrap() - } - - async fn make_test_user_and_device(name: &str, pool: &PgPool) -> User { - let user = User::new( - name, - None, - "lastname", - "firstname", - format!("{name}@email.com").as_str(), - None, - ) - .save(pool) - .await - .unwrap(); - - let dev = Device::new( - format!("{name}-device"), - format!("{name}-key"), - user.id, - DeviceType::User, - None, - true, - ) - .save(pool) - .await - .unwrap(); - - let mut transaction = pool.begin().await.unwrap(); - dev.add_to_all_networks(&mut transaction).await.unwrap(); - transaction.commit().await.unwrap(); - - user - } - - async fn get_test_user(pool: &PgPool, name: &str) -> Option> { - User::find_by_username(pool, name).await.unwrap() - } - - async fn make_admin(pool: &PgPool, user: &User) { - let admin_group = Group::find_by_name(pool, "admin").await.unwrap().unwrap(); - user.add_to_group(pool, &admin_group).await.unwrap(); - } - - // Keep both users and admins - #[sqlx::test] - async fn test_users_state_keep_both(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Keep, - DirectorySyncUserBehavior::Keep, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user1 = make_test_user_and_device("user1", &pool).await; - make_test_user_and_device("user2", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_admin(&pool, &user1).await; - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - // No events - assert!(wg_rx.try_recv().is_err()); - } - - // Delete users, keep admins - #[sqlx::test] - async fn test_users_state_delete_users(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Keep, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - let user2 = make_test_user_and_device("user2", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_admin(&pool, &user1).await; - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_none()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - let event = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { - assert_eq!(dev.device.user_id, user2.id); - } else { - panic!("Expected a DeviceDeleted event"); - } - } - #[sqlx::test] - async fn test_users_state_delete_admins(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - User::init_admin_user(&pool, config.default_admin_password.expose_secret()) - .await - .unwrap(); - - let _ = make_test_provider( - &pool, - DirectorySyncUserBehavior::Keep, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - make_test_user_and_device("user2", &pool).await; - let user3 = make_test_user_and_device("user3", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_admin(&pool, &user1).await; - make_admin(&pool, &user3).await; - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - assert!( - get_test_user(&pool, "user1").await.is_none() - || get_test_user(&pool, "user3").await.is_none() - ); - assert!( - get_test_user(&pool, "user1").await.is_some() - || get_test_user(&pool, "user3").await.is_some() - ); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - // Check that we received a device deleted event for whichever admin was removed - let event = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { - assert!(dev.device.user_id == user1.id || dev.device.user_id == user3.id); - } else { - panic!("Expected a DeviceDeleted event"); - } - } - - #[sqlx::test] - async fn test_users_state_delete_both(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - User::init_admin_user(&pool, config.default_admin_password.expose_secret()) - .await - .unwrap(); - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - let user2 = make_test_user_and_device("user2", &pool).await; - let user3 = make_test_user_and_device("user3", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_admin(&pool, &user1).await; - make_admin(&pool, &user3).await; - - assert!(get_test_user(&pool, "user1").await.is_some()); - assert!(get_test_user(&pool, "user2").await.is_some()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - assert!( - get_test_user(&pool, "user1").await.is_none() - || get_test_user(&pool, "user3").await.is_none() - ); - assert!( - get_test_user(&pool, "user1").await.is_some() - || get_test_user(&pool, "user3").await.is_some() - ); - assert!(get_test_user(&pool, "user2").await.is_none()); - assert!(get_test_user(&pool, "testuser").await.is_some()); - - // Check for device deletion events - let event1 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { - assert!( - dev.device.user_id == user1.id - || dev.device.user_id == user2.id - || dev.device.user_id == user3.id - ); - } else { - panic!("Expected a DeviceDeleted event"); - } - - let event2 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { - assert!( - dev.device.user_id == user1.id - || dev.device.user_id == user2.id - || dev.device.user_id == user3.id - ); - } else { - panic!("Expected a DeviceDeleted event"); - } - } - - #[sqlx::test] - async fn test_users_state_disable_users(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Disable, - DirectorySyncUserBehavior::Keep, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - make_test_user_and_device("user2", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_test_user_and_device("testuserdisabled", &pool).await; - make_admin(&pool, &user1).await; - - let user1 = get_test_user(&pool, "user1").await.unwrap(); - let user2 = get_test_user(&pool, "user2").await.unwrap(); - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - let disabled_user_session = Session::new( - testuserdisabled.id, - SessionState::PasswordVerified, - "127.0.0.1".into(), - None, - ); - disabled_user_session.save(&pool).await.unwrap(); - assert!( - Session::find_by_id(&pool, &disabled_user_session.id) - .await - .unwrap() - .is_some() - ); - - assert!(user1.is_active); - assert!(user2.is_active); - assert!(testuser.is_active); - assert!(testuserdisabled.is_active); - - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - // Check for device disconnection events - let event1 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { - assert!(dev.device.user_id == user2.id || dev.device.user_id == testuserdisabled.id); - } else { - panic!("Expected a DeviceDisconnected event"); - } - - let event2 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { - assert!(dev.device.user_id == user2.id || dev.device.user_id == testuserdisabled.id); - } else { - panic!("Expected a DeviceDisconnected event"); - } - - let user1 = get_test_user(&pool, "user1").await.unwrap(); - let user2 = get_test_user(&pool, "user2").await.unwrap(); - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - - assert!( - Session::find_by_id(&pool, &disabled_user_session.id) - .await - .unwrap() - .is_none() - ); - assert!(user1.is_active); - assert!(!user2.is_active); - assert!(testuser.is_active); - assert!(!testuserdisabled.is_active); - } - #[sqlx::test] - async fn test_users_state_disable_admins(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); // Added mut wg_rx - make_test_provider( - &pool, - DirectorySyncUserBehavior::Keep, - DirectorySyncUserBehavior::Disable, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - let user1 = make_test_user_and_device("user1", &pool).await; - make_test_user_and_device("user2", &pool).await; - let user3 = make_test_user_and_device("user3", &pool).await; - make_test_user_and_device("testuser", &pool).await; - make_test_user_and_device("testuserdisabled", &pool).await; - make_admin(&pool, &user1).await; - make_admin(&pool, &user3).await; - - let user1 = get_test_user(&pool, "user1").await.unwrap(); - let user2 = get_test_user(&pool, "user2").await.unwrap(); - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - - assert!(user1.is_active); - assert!(user2.is_active); - assert!(user3.is_active); - assert!(testuser.is_active); - assert!(testuserdisabled.is_active); - - sync_all_users_state(&client, &pool, &wg_tx).await.unwrap(); - - // Check for device disconnection events - let event1 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { - assert!( - dev.device.user_id == user1.id - || dev.device.user_id == user3.id - || dev.device.user_id == testuserdisabled.id - ); - } else { - panic!("Expected a DeviceDisconnected event"); - } - - let event2 = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { - assert!( - dev.device.user_id == user1.id - || dev.device.user_id == user3.id - || dev.device.user_id == testuserdisabled.id - ); - } else { - panic!("Expected a DeviceDisconnected event"); - } - - let user1 = get_test_user(&pool, "user1").await.unwrap(); - let user2 = get_test_user(&pool, "user2").await.unwrap(); - let user3 = get_test_user(&pool, "user3").await.unwrap(); - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - - assert!(!user1.is_active || !user3.is_active); - assert!(user1.is_active || user3.is_active); - assert!(user2.is_active); - assert!(testuser.is_active); - assert!(!testuserdisabled.is_active); - } - - #[sqlx::test] - async fn test_users_groups(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - make_test_user_and_device("testuser", &pool).await; - make_test_user_and_device("testuser2", &pool).await; - make_test_user_and_device("testuserdisabled", &pool).await; - sync_all_users_groups(&client, &pool, &wg_tx).await.unwrap(); - - let mut groups = Group::all(&pool).await.unwrap(); - - let testuser = get_test_user(&pool, "testuser").await.unwrap(); - let testuser2 = get_test_user(&pool, "testuser2").await.unwrap(); - let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); - - let testuser_groups = testuser.member_of(&pool).await.unwrap(); - let testuser2_groups = testuser2.member_of(&pool).await.unwrap(); - let testuserdisabled_groups = testuserdisabled.member_of(&pool).await.unwrap(); - - assert_eq!(testuser_groups.len(), 3); - assert_eq!(testuser2_groups.len(), 3); - assert_eq!(testuserdisabled_groups.len(), 3); - groups.sort_by(|a, b| a.name.cmp(&b.name)); - - let group_present = - |groups: &Vec>, name: &str| groups.iter().any(|g| g.name == name); - - assert!(group_present(&testuser_groups, "group1")); - assert!(group_present(&testuser_groups, "group2")); - assert!(group_present(&testuser_groups, "group3")); - - assert!(group_present(&testuser2_groups, "group1")); - assert!(group_present(&testuser2_groups, "group2")); - assert!(group_present(&testuser2_groups, "group3")); - - assert!(group_present(&testuserdisabled_groups, "group1")); - assert!(group_present(&testuserdisabled_groups, "group2")); - assert!(group_present(&testuserdisabled_groups, "group3")); - } - - #[sqlx::test] - async fn test_sync_user_groups(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user = make_test_user_and_device("testuser", &pool).await; - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - sync_user_groups_if_configured(&user, &pool, &wg_tx) - .await - .unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 1); - let group = Group::find_by_name(&pool, "group1").await.unwrap().unwrap(); - assert_eq!(user_groups[0].id, group.id); - } - - #[sqlx::test] - async fn test_sync_target_users(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::Users, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user = make_test_user_and_device("testuser", &pool).await; - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - do_directory_sync(&pool, &wg_tx).await.unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - } - - #[sqlx::test] - async fn test_sync_target_all(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, mut wg_rx) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let network = get_test_network(&pool).await; - let mut transaction = pool.begin().await.unwrap(); - let group = Group::new("group1".to_string()) - .save(&mut *transaction) - .await - .unwrap(); - network - .set_allowed_groups(&mut transaction, vec![group.name]) - .await - .unwrap(); - transaction.commit().await.unwrap(); - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user = make_test_user_and_device("testuser", &pool).await; - let user2_pre_sync = make_test_user_and_device("user2", &pool).await; - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - do_directory_sync(&pool, &wg_tx).await.unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 3); - let user2 = get_test_user(&pool, "user2").await; - assert!(user2.is_none()); - let mut transaction = pool.begin().await.unwrap(); - user.sync_allowed_devices(&mut transaction, &wg_tx) - .await - .unwrap(); - transaction.commit().await.unwrap(); - let event = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { - assert_eq!(dev.device.user_id, user2_pre_sync.id); - } else { - panic!("Expected a DeviceDeleted event"); - } - let event = wg_rx.try_recv(); - if let Ok(GatewayEvent::DeviceCreated(dev)) = event { - assert_eq!(dev.device.user_id, user.id); - } else { - panic!("Expected a DeviceDeleted event"); - } - } - - #[sqlx::test] - async fn test_sync_target_groups(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::Groups, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - let user = make_test_user_and_device("testuser", &pool).await; - make_test_user_and_device("user2", &pool).await; - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 0); - do_directory_sync(&pool, &wg_tx).await.unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 3); - let user2 = get_test_user(&pool, "user2").await; - assert!(user2.is_some()); - } - - #[sqlx::test] - async fn test_sync_unassign_last_admin_group(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - // Make one admin and check if he's deleted - let user = make_test_user_and_device("testuser", &pool).await; - let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); - user.add_to_group(&pool, &admin_grp).await.unwrap(); - let user_groups = user.member_of(&pool).await.unwrap(); - assert_eq!(user_groups.len(), 1); - assert!(user.is_admin(&pool).await.unwrap()); - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - - // He should still be an admin as it's the last one - assert!(user.is_admin(&pool).await.unwrap()); - - // Make another admin and check if one of them is deleted - let user2 = make_test_user_and_device("testuser2", &pool).await; - user2.add_to_group(&pool, &admin_grp).await.unwrap(); - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - - let admins = User::find_admins(&pool).await.unwrap(); - // There should be only one admin left - assert_eq!(admins.len(), 1); - - let defguard_user = make_test_user_and_device("defguard", &pool).await; - make_admin(&pool, &defguard_user).await; - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - } - - #[sqlx::test] - async fn test_sync_delete_last_admin_user(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = DefGuardConfig::new_test_config(); - let _ = SERVER_CONFIG.set(config.clone()); - let (wg_tx, _) = broadcast::channel::(16); - make_test_provider( - &pool, - DirectorySyncUserBehavior::Delete, - DirectorySyncUserBehavior::Delete, - DirectorySyncTarget::All, - ) - .await; - let mut client = DirectorySyncClient::build(&pool).await.unwrap(); - client.prepare().await.unwrap(); - - // a user that's not in the directory - let defguard_user = make_test_user_and_device("defguard", &pool).await; - make_admin(&pool, &defguard_user).await; - assert!(defguard_user.is_admin(&pool).await.unwrap()); - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - - // The user should still be an admin - assert!(defguard_user.is_admin(&pool).await.unwrap()); - - // remove his admin status - let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); - defguard_user - .remove_from_group(&pool, &admin_grp) - .await - .unwrap(); - - do_directory_sync(&pool, &wg_tx).await.unwrap(); - let user = User::find_by_username(&pool, "defguard").await.unwrap(); - assert!(user.is_none()); - } -} diff --git a/crates/defguard_core/src/enterprise/directory_sync/okta.rs b/crates/defguard_core/src/enterprise/directory_sync/okta.rs index cc3f2bf33e..bbc168fe16 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/okta.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/okta.rs @@ -95,6 +95,7 @@ impl From for DirectoryUser { Self { email: val.profile.email, active: ACTIVE_STATUS.contains(&val.status.as_str()), + id: None, } } } @@ -412,17 +413,18 @@ impl DirectorySync for OktaDirectorySync { async fn get_user_groups( &self, - user_id: &str, + user_email: &str, ) -> Result, DirectorySyncError> { - debug!("Getting groups of user {user_id}"); - let response = self.query_user_groups(user_id).await?; - debug!("Got groups response for user {user_id}"); + debug!("Getting groups of user {user_email}"); + let response = self.query_user_groups(user_email).await?; + debug!("Got groups response for user {user_email}"); Ok(response.into_iter().map(Into::into).collect()) } async fn get_group_members( &self, group: &DirectoryGroup, + _all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError> { debug!("Getting group members of group {}", group.name); let response = self.query_group_members(group).await?; diff --git a/crates/defguard_core/src/enterprise/directory_sync/testprovider.rs b/crates/defguard_core/src/enterprise/directory_sync/testprovider.rs index 1083889588..fc74bdebfd 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/testprovider.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/testprovider.rs @@ -23,7 +23,7 @@ impl DirectorySync for TestProviderDirectorySync { async fn get_user_groups( &self, - _user_id: &str, + _user_email: &str, ) -> Result, DirectorySyncError> { Ok(vec![DirectoryGroup { id: "1".into(), @@ -34,6 +34,7 @@ impl DirectorySync for TestProviderDirectorySync { async fn get_group_members( &self, _group: &DirectoryGroup, + _all_users_helper: Option<&[DirectoryUser]>, ) -> Result, DirectorySyncError> { Ok(vec![ "testuser@email.com".into(), @@ -51,14 +52,17 @@ impl DirectorySync for TestProviderDirectorySync { DirectoryUser { email: "testuser@email.com".into(), active: true, + id: Some("testuser-id".into()), }, DirectoryUser { email: "testuserdisabled@email.com".into(), active: false, + id: Some("testuserdisabled-id".into()), }, DirectoryUser { email: "testuser2@email.com".into(), active: true, + id: Some("testuser2-id".into()), }, ]) } diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests.rs b/crates/defguard_core/src/enterprise/directory_sync/tests.rs new file mode 100644 index 0000000000..54df5c6a2c --- /dev/null +++ b/crates/defguard_core/src/enterprise/directory_sync/tests.rs @@ -0,0 +1,772 @@ +#[cfg(test)] +mod test { + use std::str::FromStr; + + use ipnetwork::IpNetwork; + use secrecy::ExposeSecret; + use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + use tokio::sync::broadcast; + + use super::super::*; + use crate::{ + SERVER_CONFIG, + config::DefGuardConfig, + db::{ + Device, Session, SessionState, Settings, WireguardNetwork, + models::{ + device::DeviceType, settings::initialize_current_settings, + wireguard::LocationMfaMode, + }, + setup_pool, + }, + enterprise::db::models::openid_provider::DirectorySyncTarget, + }; + + async fn get_test_network(pool: &PgPool) -> WireguardNetwork { + WireguardNetwork::find_by_name(pool, "test") + .await + .unwrap() + .unwrap() + .pop() + .unwrap() + } + + async fn make_test_provider( + pool: &PgPool, + user_behavior: DirectorySyncUserBehavior, + admin_behavior: DirectorySyncUserBehavior, + target: DirectorySyncTarget, + ) -> OpenIdProvider { + Settings::init_defaults(pool).await.unwrap(); + initialize_current_settings(pool).await.unwrap(); + + let current = OpenIdProvider::get_current(pool).await.unwrap(); + + if let Some(provider) = current { + provider.delete(pool).await.unwrap(); + } + + WireguardNetwork::new( + "test".to_string(), + vec![IpNetwork::from_str("10.10.10.1/24").unwrap()], + 1234, + "123.123.123.123".to_string(), + None, + vec![], + 32, + 32, + false, + false, + LocationMfaMode::Disabled, + ) + .save(pool) + .await + .unwrap(); + + OpenIdProvider::new( + "Test".to_string(), + "base_url".to_string(), + "client_id".to_string(), + "client_secret".to_string(), + Some("display_name".to_string()), + Some("google_service_account_key".to_string()), + Some("google_service_account_email".to_string()), + Some("admin_email".to_string()), + true, + 60, + user_behavior, + admin_behavior, + target, + None, + None, + vec![], + None, + ) + .save(pool) + .await + .unwrap() + } + + async fn make_test_user_and_device(name: &str, pool: &PgPool) -> User { + let user = User::new( + name, + None, + "lastname", + "firstname", + format!("{name}@email.com").as_str(), + None, + ) + .save(pool) + .await + .unwrap(); + + let dev = Device::new( + format!("{name}-device"), + format!("{name}-key"), + user.id, + DeviceType::User, + None, + true, + ) + .save(pool) + .await + .unwrap(); + + let mut transaction = pool.begin().await.unwrap(); + dev.add_to_all_networks(&mut transaction).await.unwrap(); + transaction.commit().await.unwrap(); + + user + } + + async fn get_test_user(pool: &PgPool, name: &str) -> Option> { + User::find_by_username(pool, name).await.unwrap() + } + + async fn make_admin(pool: &PgPool, user: &User) { + let admin_group = Group::find_by_name(pool, "admin").await.unwrap().unwrap(); + user.add_to_group(pool, &admin_group).await.unwrap(); + } + + // Keep both users and admins + #[sqlx::test] + async fn test_users_state_keep_both(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user1 = make_test_user_and_device("user1", &pool).await; + make_test_user_and_device("user2", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_admin(&pool, &user1).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&pool, &wg_tx, &all_users) + .await + .unwrap(); + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + // No events + assert!(wg_rx.try_recv().is_err()); + } + + // Delete users, keep admins + #[sqlx::test] + async fn test_users_state_delete_users(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + let user2 = make_test_user_and_device("user2", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_admin(&pool, &user1).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&pool, &wg_tx, &all_users) + .await + .unwrap(); + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_none()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + let event = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { + assert_eq!(dev.device.user_id, user2.id); + } else { + panic!("Expected a DeviceDeleted event"); + } + } + #[sqlx::test] + async fn test_users_state_delete_admins(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + User::init_admin_user(&pool, config.default_admin_password.expose_secret()) + .await + .unwrap(); + + let _ = make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + make_test_user_and_device("user2", &pool).await; + let user3 = make_test_user_and_device("user3", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_admin(&pool, &user1).await; + make_admin(&pool, &user3).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&pool, &wg_tx, &all_users) + .await + .unwrap(); + + assert!( + get_test_user(&pool, "user1").await.is_none() + || get_test_user(&pool, "user3").await.is_none() + ); + assert!( + get_test_user(&pool, "user1").await.is_some() + || get_test_user(&pool, "user3").await.is_some() + ); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + // Check that we received a device deleted event for whichever admin was removed + let event = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { + assert!(dev.device.user_id == user1.id || dev.device.user_id == user3.id); + } else { + panic!("Expected a DeviceDeleted event"); + } + } + + #[sqlx::test] + async fn test_users_state_delete_both(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + User::init_admin_user(&pool, config.default_admin_password.expose_secret()) + .await + .unwrap(); + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + let user2 = make_test_user_and_device("user2", &pool).await; + let user3 = make_test_user_and_device("user3", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_admin(&pool, &user1).await; + make_admin(&pool, &user3).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&pool, &wg_tx, &all_users) + .await + .unwrap(); + + assert!( + get_test_user(&pool, "user1").await.is_none() + || get_test_user(&pool, "user3").await.is_none() + ); + assert!( + get_test_user(&pool, "user1").await.is_some() + || get_test_user(&pool, "user3").await.is_some() + ); + assert!(get_test_user(&pool, "user2").await.is_none()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + // Check for device deletion events + let event1 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { + assert!( + dev.device.user_id == user1.id + || dev.device.user_id == user2.id + || dev.device.user_id == user3.id + ); + } else { + panic!("Expected a DeviceDeleted event"); + } + + let event2 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { + assert!( + dev.device.user_id == user1.id + || dev.device.user_id == user2.id + || dev.device.user_id == user3.id + ); + } else { + panic!("Expected a DeviceDeleted event"); + } + } + + #[sqlx::test] + async fn test_users_state_disable_users(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Disable, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + make_test_user_and_device("user2", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_test_user_and_device("testuserdisabled", &pool).await; + make_admin(&pool, &user1).await; + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + let disabled_user_session = Session::new( + testuserdisabled.id, + SessionState::PasswordVerified, + "127.0.0.1".into(), + None, + ); + disabled_user_session.save(&pool).await.unwrap(); + assert!( + Session::find_by_id(&pool, &disabled_user_session.id) + .await + .unwrap() + .is_some() + ); + + assert!(user1.is_active); + assert!(user2.is_active); + assert!(testuser.is_active); + assert!(testuserdisabled.is_active); + + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&pool, &wg_tx, &all_users) + .await + .unwrap(); + + // Check for device disconnection events + let event1 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { + assert!(dev.device.user_id == user2.id || dev.device.user_id == testuserdisabled.id); + } else { + panic!("Expected a DeviceDisconnected event"); + } + + let event2 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { + assert!(dev.device.user_id == user2.id || dev.device.user_id == testuserdisabled.id); + } else { + panic!("Expected a DeviceDisconnected event"); + } + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!( + Session::find_by_id(&pool, &disabled_user_session.id) + .await + .unwrap() + .is_none() + ); + assert!(user1.is_active); + assert!(!user2.is_active); + assert!(testuser.is_active); + assert!(!testuserdisabled.is_active); + } + #[sqlx::test] + async fn test_users_state_disable_admins(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); // Added mut wg_rx + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Disable, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user_and_device("user1", &pool).await; + make_test_user_and_device("user2", &pool).await; + let user3 = make_test_user_and_device("user3", &pool).await; + make_test_user_and_device("testuser", &pool).await; + make_test_user_and_device("testuserdisabled", &pool).await; + make_admin(&pool, &user1).await; + make_admin(&pool, &user3).await; + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!(user1.is_active); + assert!(user2.is_active); + assert!(user3.is_active); + assert!(testuser.is_active); + assert!(testuserdisabled.is_active); + + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_state(&pool, &wg_tx, &all_users) + .await + .unwrap(); + + // Check for device disconnection events + let event1 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event1 { + assert!( + dev.device.user_id == user1.id + || dev.device.user_id == user3.id + || dev.device.user_id == testuserdisabled.id + ); + } else { + panic!("Expected a DeviceDisconnected event"); + } + + let event2 = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event2 { + assert!( + dev.device.user_id == user1.id + || dev.device.user_id == user3.id + || dev.device.user_id == testuserdisabled.id + ); + } else { + panic!("Expected a DeviceDisconnected event"); + } + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let user3 = get_test_user(&pool, "user3").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!(!user1.is_active || !user3.is_active); + assert!(user1.is_active || user3.is_active); + assert!(user2.is_active); + assert!(testuser.is_active); + assert!(!testuserdisabled.is_active); + } + + #[sqlx::test] + async fn test_users_groups(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + make_test_user_and_device("testuser", &pool).await; + make_test_user_and_device("testuser2", &pool).await; + make_test_user_and_device("testuserdisabled", &pool).await; + let all_users = client.get_all_users().await.unwrap(); + sync_all_users_groups(&client, &pool, &wg_tx, Some(&all_users)) + .await + .unwrap(); + + let mut groups = Group::all(&pool).await.unwrap(); + + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuser2 = get_test_user(&pool, "testuser2").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + let testuser_groups = testuser.member_of(&pool).await.unwrap(); + let testuser2_groups = testuser2.member_of(&pool).await.unwrap(); + let testuserdisabled_groups = testuserdisabled.member_of(&pool).await.unwrap(); + + assert_eq!(testuser_groups.len(), 3); + assert_eq!(testuser2_groups.len(), 3); + assert_eq!(testuserdisabled_groups.len(), 3); + groups.sort_by(|a, b| a.name.cmp(&b.name)); + + let group_present = + |groups: &Vec>, name: &str| groups.iter().any(|g| g.name == name); + + assert!(group_present(&testuser_groups, "group1")); + assert!(group_present(&testuser_groups, "group2")); + assert!(group_present(&testuser_groups, "group3")); + + assert!(group_present(&testuser2_groups, "group1")); + assert!(group_present(&testuser2_groups, "group2")); + assert!(group_present(&testuser2_groups, "group3")); + + assert!(group_present(&testuserdisabled_groups, "group1")); + assert!(group_present(&testuserdisabled_groups, "group2")); + assert!(group_present(&testuserdisabled_groups, "group3")); + } + + #[sqlx::test] + async fn test_sync_user_groups(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user_and_device("testuser", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + sync_user_groups_if_configured(&user, &pool, &wg_tx) + .await + .unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + let group = Group::find_by_name(&pool, "group1").await.unwrap().unwrap(); + assert_eq!(user_groups[0].id, group.id); + } + + #[sqlx::test] + async fn test_sync_target_users(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::Users, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user_and_device("testuser", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool, &wg_tx).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + } + + #[sqlx::test] + async fn test_sync_target_all(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, mut wg_rx) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let network = get_test_network(&pool).await; + let mut transaction = pool.begin().await.unwrap(); + let group = Group::new("group1".to_string()) + .save(&mut *transaction) + .await + .unwrap(); + network + .set_allowed_groups(&mut transaction, vec![group.name]) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user_and_device("testuser", &pool).await; + let user2_pre_sync = make_test_user_and_device("user2", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool, &wg_tx).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 3); + let user2 = get_test_user(&pool, "user2").await; + assert!(user2.is_none()); + let mut transaction = pool.begin().await.unwrap(); + user.sync_allowed_devices(&mut transaction, &wg_tx) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let event = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceDeleted(dev)) = event { + assert_eq!(dev.device.user_id, user2_pre_sync.id); + } else { + panic!("Expected a DeviceDeleted event"); + } + let event = wg_rx.try_recv(); + if let Ok(GatewayEvent::DeviceCreated(dev)) = event { + assert_eq!(dev.device.user_id, user.id); + } else { + panic!("Expected a DeviceDeleted event"); + } + } + + #[sqlx::test] + async fn test_sync_target_groups(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::Groups, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user_and_device("testuser", &pool).await; + make_test_user_and_device("user2", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool, &wg_tx).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 3); + let user2 = get_test_user(&pool, "user2").await; + assert!(user2.is_some()); + } + + #[sqlx::test] + async fn test_sync_unassign_last_admin_group(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + // Make one admin and check if he's deleted + let user = make_test_user_and_device("testuser", &pool).await; + let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); + user.add_to_group(&pool, &admin_grp).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + assert!(user.is_admin(&pool).await.unwrap()); + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + + // He should still be an admin as it's the last one + assert!(user.is_admin(&pool).await.unwrap()); + + // Make another admin and check if one of them is deleted + let user2 = make_test_user_and_device("testuser2", &pool).await; + user2.add_to_group(&pool, &admin_grp).await.unwrap(); + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + + let admins = User::find_admins(&pool).await.unwrap(); + // There should be only one admin left + assert_eq!(admins.len(), 1); + + let defguard_user = make_test_user_and_device("defguard", &pool).await; + make_admin(&pool, &defguard_user).await; + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + } + + #[sqlx::test] + async fn test_sync_delete_last_admin_user(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (wg_tx, _) = broadcast::channel::(16); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + // a user that's not in the directory + let defguard_user = make_test_user_and_device("defguard", &pool).await; + make_admin(&pool, &defguard_user).await; + assert!(defguard_user.is_admin(&pool).await.unwrap()); + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + + // The user should still be an admin + assert!(defguard_user.is_admin(&pool).await.unwrap()); + + // remove his admin status + let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); + defguard_user + .remove_from_group(&pool, &admin_grp) + .await + .unwrap(); + + do_directory_sync(&pool, &wg_tx).await.unwrap(); + let user = User::find_by_username(&pool, "defguard").await.unwrap(); + assert!(user.is_none()); + } +} diff --git a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs index ec7187f037..069d1fd86d 100644 --- a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs +++ b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs @@ -9,7 +9,7 @@ use crate::{ }, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, grpc::{ - desktop_client_mfa::{ClientLoginSession, ClientMfaServer}, + client_mfa::{ClientLoginSession, ClientMfaServer}, proto::proxy::{ClientMfaOidcAuthenticateRequest, DeviceInfo, MfaMethod}, utils::parse_client_info, }, @@ -52,6 +52,7 @@ impl ClientMfaServer { location, user, openid_auth_completed, + biometric_challenge: _, } = session; if openid_auth_completed { @@ -136,7 +137,7 @@ impl ClientMfaServer { })?; return Err(Status::unauthenticated("unauthorized")); } - }; + } self.sessions.insert( pubkey.clone(), @@ -146,6 +147,7 @@ impl ClientMfaServer { location: location.clone(), user: user.clone(), openid_auth_completed: true, + biometric_challenge: None, }, ); diff --git a/crates/defguard_core/src/enterprise/handlers/acl.rs b/crates/defguard_core/src/enterprise/handlers/acl.rs index e015b68e3d..d2ce367206 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl.rs @@ -247,7 +247,7 @@ pub async fn get_acl_rule( }; info!("User {} retrieved ACL rule {id}", session.user.username); - Ok(ApiResponse { json: rule, status }) + Ok(ApiResponse::new(rule, status)) } pub async fn create_acl_rule( diff --git a/crates/defguard_core/src/enterprise/handlers/api_tokens.rs b/crates/defguard_core/src/enterprise/handlers/api_tokens.rs index 37c2f52988..d76a3488ea 100644 --- a/crates/defguard_core/src/enterprise/handlers/api_tokens.rs +++ b/crates/defguard_core/src/enterprise/handlers/api_tokens.rs @@ -90,7 +90,7 @@ pub async fn fetch_api_tokens( let tokens_info: Vec = ApiToken::find_by_user_id(&appstate.pool, user.id) .await? .into_iter() - .map(|token| token.into()) + .map(Into::into) .collect(); Ok(ApiResponse { diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index 0a236218bb..0eadebbe0c 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -22,6 +22,9 @@ use time::Duration; const COOKIE_MAX_AGE: Duration = Duration::days(1); static CSRF_COOKIE_NAME: &str = "csrf"; static NONCE_COOKIE_NAME: &str = "nonce"; +// The select_account prompt is not supported by all providers, most notably not by JumpCloud. +// Currently it's only enabled for Google, as it was tested to work there. +pub(crate) const SELECT_ACCOUNT_SUPPORTED_PROVIDERS: &[&str] = &["Google"]; use super::LicenseInfo; use crate::{ @@ -49,6 +52,7 @@ use crate::{ /// - starts with non-special character /// - only special characters allowed: . - _ /// - no whitespaces +#[must_use] pub fn prune_username(username: &str, handling: OpenidUsernameHandling) -> String { let mut result = username.to_string(); @@ -86,7 +90,7 @@ pub fn prune_username(username: &str, handling: OpenidUsernameHandling) -> Strin } /// Create HTTP client and prevent following redirects -async fn get_async_http_client() -> Result { +fn get_async_http_client() -> Result { reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() @@ -102,7 +106,7 @@ async fn get_provider_metadata(url: &str) -> Result, pub directory_sync_group_match: Option, pub username_handling: OpenidUsernameHandling, + pub jumpcloud_api_key: Option, } #[derive(Debug, Deserialize, Serialize)] @@ -154,6 +155,7 @@ pub async fn add_openid_provider( okta_private_jwk, provider_data.okta_dirsync_client_id, group_match, + provider_data.jumpcloud_api_key, ) .upsert(&appstate.pool) .await?; @@ -223,7 +225,7 @@ pub async fn delete_openid_provider( let locations = WireguardNetwork::all_using_external_mfa(&mut *transaction).await?; if locations.is_empty() { debug!("No locations are using OIDC provider for external MFA"); - }; + } // fall back to internal MFA in all relevant locations for mut location in locations { debug!( diff --git a/crates/defguard_core/src/enterprise/ldap/client.rs b/crates/defguard_core/src/enterprise/ldap/client.rs index 2d0cdfec01..6e029ac291 100644 --- a/crates/defguard_core/src/enterprise/ldap/client.rs +++ b/crates/defguard_core/src/enterprise/ldap/client.rs @@ -218,7 +218,7 @@ impl super::LDAPConnection { .map(|u| (self.config.user_dn_from_user(u).to_lowercase(), u)) .collect::>(); - for entry in membership_entries.iter_mut() { + for entry in &mut membership_entries { let groupname = entry .attrs .remove(&self.config.ldap_groupname_attr) @@ -335,13 +335,16 @@ impl super::LDAPConnection { } pub(super) async fn list_users(&mut self) -> Result, LdapError> { - let filter = if !self.config.ldap_sync_groups.is_empty() { + let filter = if self.config.ldap_sync_groups.is_empty() { + debug!("No LDAP sync groups defined, searching for all users in the base DN"); + format!("(objectClass={})", self.config.ldap_user_obj_class) + } else { debug!( "LDAP sync groups are defined, filtering users by those groups: {:?}", self.config.ldap_sync_groups ); let mut group_filters = vec![]; - for group in self.config.ldap_sync_groups.iter() { + for group in &self.config.ldap_sync_groups { let group_dn = self.config.group_dn(group); let group_dn_escaped = ldap_escape(&group_dn); group_filters.push(format!( @@ -358,9 +361,6 @@ impl super::LDAPConnection { self.config.ldap_user_obj_class, group_filters.join("") ) - } else { - debug!("No LDAP sync groups defined, searching for all users in the base DN"); - format!("(objectClass={})", self.config.ldap_user_obj_class) }; debug!( diff --git a/crates/defguard_core/src/enterprise/ldap/hash.rs b/crates/defguard_core/src/enterprise/ldap/hash.rs index 7867e621fc..a5b47dbb8e 100644 --- a/crates/defguard_core/src/enterprise/ldap/hash.rs +++ b/crates/defguard_core/src/enterprise/ldap/hash.rs @@ -1,6 +1,6 @@ use base64::Engine; use md4::Md4; -use rand_core::{OsRng, RngCore}; +use rand::{RngCore, rngs::OsRng}; use sha1::{ Digest, Sha1, digest::generic_array::{GenericArray, sequence::Concat}, @@ -41,10 +41,7 @@ pub fn nthash(password: &str) -> String { #[must_use] pub fn unicode_pwd(password: &str) -> Vec { let quoted = format!("\"{password}\""); - let utf16_bytes: Vec = quoted - .encode_utf16() - .flat_map(|c| c.to_le_bytes()) - .collect(); + let utf16_bytes: Vec = quoted.encode_utf16().flat_map(u16::to_le_bytes).collect(); utf16_bytes } diff --git a/crates/defguard_core/src/enterprise/ldap/mod.rs b/crates/defguard_core/src/enterprise/ldap/mod.rs index 0057a1ac9e..c4ab28da63 100644 --- a/crates/defguard_core/src/enterprise/ldap/mod.rs +++ b/crates/defguard_core/src/enterprise/ldap/mod.rs @@ -115,7 +115,7 @@ where Err(e) => { warn!("Encountered an error while performing LDAP operation: {e:?}"); if let Err(status_err) = set_ldap_sync_status(SyncStatus::OutOfSync, pool).await { - warn!("Failed to update LDAP sync status: {:?}", status_err); + warn!("Failed to update LDAP sync status: {status_err:?}"); } Err(e) @@ -236,7 +236,7 @@ impl LDAPConfig { #[must_use] pub(crate) fn get_all_user_obj_classes(&self) -> Vec { let mut obj_classes = vec![self.ldap_user_obj_class.clone()]; - obj_classes.extend(self.ldap_user_auxiliary_obj_classes.to_vec()); + obj_classes.extend(self.ldap_user_auxiliary_obj_classes.clone()); obj_classes } @@ -247,7 +247,7 @@ impl LDAPConfig { // RDN set = username is used as RDN if they are the same self.ldap_user_rdn_attr .as_deref() - .is_none_or(|rdn| rdn == self.ldap_username_attr || rdn.is_empty()) + .is_none_or(|rdn| rdn.eq_ignore_ascii_case(&self.ldap_username_attr) || rdn.is_empty()) } } @@ -389,7 +389,6 @@ impl LDAPConnection { ); self.sync_user_data(user, pool).await?; debug!("User {user} data synchronized"); - continue; } } @@ -630,7 +629,7 @@ impl LDAPConnection { user.as_ldap_attrs( &ssha_password, &nt_password, - user_obj_classes.iter().map(|s| s.as_str()).collect(), + user_obj_classes.iter().map(String::as_str).collect(), self.config.ldap_uses_ad, &username_attr, &rdn_attr, @@ -696,7 +695,7 @@ impl LDAPConnection { .attrs .get(&self.config.ldap_groupname_attr) .and_then(|v| v.first()) - .map(|name| name.to_string()) + .map(ToString::to_string) .ok_or_else(|| { LdapError::ObjectNotFound(format!( "Couldn't extract a group name from searchentry {entry:?}." @@ -830,7 +829,7 @@ impl LDAPConnection { .map(|member| self.config.user_dn_from_user(member)) .collect::>(); let member_group_attr = self.config.ldap_group_member_attr.clone(); - let member_refs: HashSet<&str> = member_dns.iter().map(|s| s.as_str()).collect(); + let member_refs: HashSet<&str> = member_dns.iter().map(String::as_str).collect(); for member_ref in member_refs { group_attrs.push((member_group_attr.as_str(), hashset![member_ref])); @@ -842,7 +841,7 @@ impl LDAPConnection { group_name, member_dns .iter() - .map(|dn| dn.as_str()) + .map(String::as_str) .collect::>() .join(", ") ); @@ -903,11 +902,7 @@ impl LDAPConnection { debug!("User {user} is already a member of group {groupname}, skipping"); return Ok(()); } - if !self.group_exists(groupname).await? { - debug!("Group {groupname} doesn't exist in LDAP, creating it"); - self.add_group_with_members(groupname, vec![user]).await?; - debug!("Group {groupname} created and member added in LDAP"); - } else { + if self.group_exists(groupname).await? { debug!("Group {groupname} exists in LDAP, adding user {user} to it"); let group_dn = self.config.group_dn(groupname); self.modify( @@ -920,6 +915,10 @@ impl LDAPConnection { ) .await?; debug!("Added user {user} to group {groupname} in LDAP"); + } else { + debug!("Group {groupname} doesn't exist in LDAP, creating it"); + self.add_group_with_members(groupname, vec![user]).await?; + debug!("Group {groupname} created and member added in LDAP"); } info!("Added user {user} to group {groupname} in LDAP"); Ok(()) diff --git a/crates/defguard_core/src/enterprise/ldap/model.rs b/crates/defguard_core/src/enterprise/ldap/model.rs index 5313109146..bfb3a20561 100644 --- a/crates/defguard_core/src/enterprise/ldap/model.rs +++ b/crates/defguard_core/src/enterprise/ldap/model.rs @@ -82,9 +82,8 @@ impl User { // Print the warning only if everything else checks out if check_username(username).is_err() { warn!( - "LDAP User \"{}\" has username that cannot be used in Defguard, \ + "LDAP User \"{username}\" has username that cannot be used in Defguard, \ change the LDAP username attribute or change the username in LDAP to a valid one", - username ); return Err(LdapError::InvalidUsername(username.to_string())); } @@ -94,18 +93,18 @@ impl User { impl User { pub(crate) fn update_from_ldap_user(&mut self, ldap_user: &User, config: &LDAPConfig) { - self.last_name = ldap_user.last_name.clone(); - self.first_name = ldap_user.first_name.clone(); - self.email = ldap_user.email.clone(); - self.phone = ldap_user.phone.clone(); + self.last_name.clone_from(&ldap_user.last_name); + self.first_name.clone_from(&ldap_user.first_name); + self.email.clone_from(&ldap_user.email); + self.phone.clone_from(&ldap_user.phone); // It should be ok to update the username if we are not using it in the DN (not as RDN) - if !config.using_username_as_rdn() { - self.username = ldap_user.username.clone(); - } else { + if config.using_username_as_rdn() { debug!( "Not updating username {} from LDAP because it is used as RDN", self.username ); + } else { + self.username.clone_from(&ldap_user.username); } } @@ -123,11 +122,15 @@ impl User { ]); // Allow renaming the user if the CN is not a part of the RDN - if config.get_rdn_attr() != "cn" { + if !config.get_rdn_attr().eq_ignore_ascii_case("cn") { changes.push(Mod::Replace("cn", hashset![self.username.as_str()])); } - if config.ldap_username_attr != "uid" && config.ldap_user_rdn_attr != Some("uid".into()) + if !config.ldap_username_attr.eq_ignore_ascii_case("uid") + && !config + .ldap_user_rdn_attr + .as_ref() + .is_some_and(|rdn_attr| rdn_attr.eq_ignore_ascii_case("uid")) { changes.push(Mod::Replace("uid", hashset![self.username.as_str()])); } @@ -146,7 +149,7 @@ impl User { ); } - if config.ldap_uses_ad && config.get_rdn_attr() != "sAMAccountName" { + if config.ldap_uses_ad && !config.get_rdn_attr().eq_ignore_ascii_case("sAMAccountName") { changes.push(Mod::Replace( "sAMAccountName", hashset![self.username.as_str()], @@ -154,10 +157,14 @@ impl User { } let username_attr = config.ldap_username_attr.as_str(); - // add anything the user provided, if we haven't already added it AND it's not the same as the RDN - if username_attr != "sAMAccountName" - && username_attr != "cn" - && Some(username_attr.into()) != config.ldap_user_rdn_attr + // Add anything the user provided, if we haven't already added it AND it's not the same as + // the RDN. + if !username_attr.eq_ignore_ascii_case("sAMAccountName") + && !username_attr.eq_ignore_ascii_case("cn") + && !config + .ldap_user_rdn_attr + .as_ref() + .is_some_and(|rdn_attr| rdn_attr.eq_ignore_ascii_case(username_attr)) { changes.push(Mod::Replace( username_attr, @@ -169,8 +176,14 @@ impl User { } // check if key is already in attrs, if not return false + #[cfg(test)] + pub(crate) fn in_attrs<'a>(attrs: &'a Vec<(&'a str, HashSet<&'a str>)>, key: &str) -> bool { + attrs.iter().any(|(k, _)| k.eq_ignore_ascii_case(key)) + } + + #[cfg(not(test))] fn in_attrs<'a>(attrs: &'a Vec<(&'a str, HashSet<&'a str>)>, key: &str) -> bool { - attrs.iter().any(|(k, _)| *k == key) + attrs.iter().any(|(k, _)| k.eq_ignore_ascii_case(key)) } #[must_use] @@ -228,13 +241,13 @@ impl User { attrs.push(("objectClass", object_classes)); - debug!("Generated LDAP attributes: {:?}", attrs); + debug!("Generated LDAP attributes: {attrs:?}"); attrs } /// Updates the LDAP RDN value of the user in Defguard, if Defguard uses the usernames as RDN. - pub(crate) async fn maybe_update_rdn(&mut self) { + pub(crate) fn maybe_update_rdn(&mut self) { debug!("Updating RDN for user {} in Defguard", self.username); let settings = Settings::get_current_settings(); if settings.ldap_using_username_as_rdn() { @@ -320,3 +333,58 @@ pub(crate) fn extract_dn_path(dn: &str) -> Option { None } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_in_attrs() { + // Create test attributes with mixed case keys + let attrs = vec![ + ("cn", hashset!["user1"]), + ("Mail", hashset!["user@example.com"]), + ("PHONE", hashset!["123456789"]), + ("givenName", hashset!["John"]), + ]; + + // Test exact case match + assert!(User::<()>::in_attrs(&attrs, "cn")); + assert!(User::<()>::in_attrs(&attrs, "Mail")); + assert!(User::<()>::in_attrs(&attrs, "PHONE")); + assert!(User::<()>::in_attrs(&attrs, "givenName")); + + // Test case-insensitive matching + assert!(User::<()>::in_attrs(&attrs, "CN")); + assert!(User::<()>::in_attrs(&attrs, "cn")); + assert!(User::<()>::in_attrs(&attrs, "mail")); + assert!(User::<()>::in_attrs(&attrs, "MAIL")); + assert!(User::<()>::in_attrs(&attrs, "phone")); + assert!(User::<()>::in_attrs(&attrs, "Phone")); + assert!(User::<()>::in_attrs(&attrs, "GIVENNAME")); + assert!(User::<()>::in_attrs(&attrs, "givenname")); + + // Test non-existent attributes + assert!(!User::<()>::in_attrs(&attrs, "nonexistent")); + assert!(!User::<()>::in_attrs(&attrs, "sn")); + assert!(!User::<()>::in_attrs(&attrs, "uid")); + + // Test empty attributes vector + let empty_attrs = vec![]; + assert!(!User::<()>::in_attrs(&empty_attrs, "cn")); + assert!(!User::<()>::in_attrs(&empty_attrs, "any")); + + // Test with empty string key + assert!(!User::<()>::in_attrs(&attrs, "")); + + // Test with attributes that have empty values (should still match on key) + let attrs_with_empty_values = vec![ + ("cn", HashSet::new()), + ("mail", hashset!["test@example.com"]), + ]; + assert!(User::<()>::in_attrs(&attrs_with_empty_values, "cn")); + assert!(User::<()>::in_attrs(&attrs_with_empty_values, "CN")); + assert!(User::<()>::in_attrs(&attrs_with_empty_values, "mail")); + assert!(!User::<()>::in_attrs(&attrs_with_empty_values, "phone")); + } +} diff --git a/crates/defguard_core/src/enterprise/ldap/sync.rs b/crates/defguard_core/src/enterprise/ldap/sync.rs index 201ad6563f..d7a795b6f5 100644 --- a/crates/defguard_core/src/enterprise/ldap/sync.rs +++ b/crates/defguard_core/src/enterprise/ldap/sync.rs @@ -94,11 +94,13 @@ pub enum SyncStatus { } impl SyncStatus { + #[must_use] pub fn is_out_of_sync(&self) -> bool { matches!(self, SyncStatus::OutOfSync) } } +#[must_use] pub fn get_ldap_sync_status() -> SyncStatus { let settings = Settings::get_current_settings(); settings.ldap_sync_status @@ -113,6 +115,7 @@ pub async fn set_ldap_sync_status(status: SyncStatus, pool: &PgPool) -> Result<( Ok(()) } +#[must_use] pub fn is_ldap_desynced() -> bool { get_ldap_sync_status().is_out_of_sync() } @@ -244,7 +247,7 @@ pub(super) fn compute_group_sync_changes<'a>( ldap_config.user_dn_from_user(m) == ldap_config.user_dn_from_user(u) }) }) - .cloned() + .copied() .collect::>(); let missing_from_ldap = members @@ -261,7 +264,9 @@ pub(super) fn compute_group_sync_changes<'a>( "Group {group:?} members missing from Defguard: {missing_from_defguard:?}, missing from LDAP: {missing_from_ldap:?}" ); - if !missing_from_defguard.is_empty() { + if missing_from_defguard.is_empty() { + debug!("Group {group:?} has no members missing from Defguard"); + } else { match authority { Authority::Defguard => { debug!( @@ -276,11 +281,11 @@ pub(super) fn compute_group_sync_changes<'a>( add_defguard.insert(group.clone(), missing_from_defguard); } } - } else { - debug!("Group {group:?} has no members missing from Defguard"); } - if !missing_from_ldap.is_empty() { + if missing_from_ldap.is_empty() { + debug!("Group {group:?} has no members missing from LDAP"); + } else { match authority { Authority::Defguard => { debug!( @@ -295,8 +300,6 @@ pub(super) fn compute_group_sync_changes<'a>( delete_defguard.insert(group.clone(), missing_from_ldap); } } - } else { - debug!("Group {group:?} has no members missing from LDAP"); } } else { match authority { @@ -418,7 +421,7 @@ pub(super) fn extract_intersecting_users( } } - for user in intersecting_users_ldap.into_iter() { + for user in intersecting_users_ldap { if let Some(defguard_user) = defguard_users .iter() .position(|u| ldap_config.user_dn_from_user(u) == ldap_config.user_dn_from_user(&user)) @@ -433,6 +436,7 @@ pub(super) fn extract_intersecting_users( const DEFAULT_LDAP_SYNC_INTERVAL: u64 = 60 * 5; +#[must_use] pub fn get_ldap_sync_interval() -> u64 { let settings = Settings::get_current_settings(); settings @@ -451,7 +455,7 @@ impl super::LDAPConnection { ) -> Result<(), LdapError> { let mut transaction = pool.begin().await?; - for (ldap_user, defguard_user) in intersecting_users.iter_mut() { + for (ldap_user, defguard_user) in &mut intersecting_users { if attrs_different(defguard_user, ldap_user, &self.config) { debug!( "User {defguard_user} attributes differ between LDAP and Defguard, merging..." @@ -860,7 +864,7 @@ impl super::LDAPConnection { self.delete_user(&user).await?; } - for user in changes.add_ldap.iter_mut() { + for user in &mut changes.add_ldap { debug!("Adding user {} to LDAP", user.username); self.add_user(user, None, pool).await?; } diff --git a/crates/defguard_core/src/enterprise/ldap/test_client.rs b/crates/defguard_core/src/enterprise/ldap/test_client.rs index 07b23dbc1c..05f2748645 100644 --- a/crates/defguard_core/src/enterprise/ldap/test_client.rs +++ b/crates/defguard_core/src/enterprise/ldap/test_client.rs @@ -278,10 +278,10 @@ impl super::LDAPConnection { if let Some((attr, value)) = search_value { for (dn, object) in &self.test_client.objects { if let Object::User(user) = object { - let matches = if attr == username_attr { + let matches = if attr.eq_ignore_ascii_case(&username_attr) { user.username == value - } else if attr == rdn_attr { - let rdn_value = if rdn_attr == username_attr { + } else if attr.eq_ignore_ascii_case(&rdn_attr) { + let rdn_value = if rdn_attr.eq_ignore_ascii_case(&username_attr) { &user.username } else { dn.split(',') diff --git a/crates/defguard_core/src/enterprise/ldap/tests.rs b/crates/defguard_core/src/enterprise/ldap/tests.rs index dda1cc2bb9..8d9d47fe4b 100644 --- a/crates/defguard_core/src/enterprise/ldap/tests.rs +++ b/crates/defguard_core/src/enterprise/ldap/tests.rs @@ -2846,7 +2846,11 @@ fn test_as_ldap_attrs() { "cn", ); - assert!(!attrs.iter().any(|(key, _)| *key == "mobile")); + assert!( + !attrs + .iter() + .any(|(key, _)| key.eq_ignore_ascii_case("mobile")) + ); } #[test] diff --git a/crates/defguard_core/src/enterprise/ldap/utils.rs b/crates/defguard_core/src/enterprise/ldap/utils.rs index 97230f2bbc..d952e8e22f 100644 --- a/crates/defguard_core/src/enterprise/ldap/utils.rs +++ b/crates/defguard_core/src/enterprise/ldap/utils.rs @@ -1,6 +1,5 @@ -//! -//! This module contains utility functions for LDAP operations. Those operations are designed to be used from outside of the module. -//! +//! This module contains utility functions for LDAP operations. Those operations are designed to be +//! used from outside of the module. use std::collections::{HashMap, HashSet}; @@ -26,20 +25,21 @@ pub(crate) async fn login_through_ldap( .get_user_by_credentials(username, password) .await?; if !ldap_connection.user_in_ldap_sync_groups(&ldap_user).await? { - info!("User {username} is not in LDAP sync groups, not allowing to login through LDAP.",); + info!("User {username} is not in LDAP sync groups, not allowing to login through LDAP."); return Err(LdapError::UserNotInLDAPSyncGroups( username.to_string(), "LDAP", )); } debug!("User {ldap_user} logged in through LDAP"); - // The user is logging in through LDAP, so we can infer that there are no other login options (Defguard password), - // so we should mark them as from_ldap. + // The user is logging in through LDAP, so we can infer that there are no other login options + // (Defguard password), so we should mark them as from_ldap. let user = if let Some(mut defguard_user) = User::find_by_username(pool, &ldap_user.username).await? { debug!( - "User {defguard_user} already exists in Defguard, marking them as coming from LDAP and proceeding with login" + "User {defguard_user} already exists in Defguard, marking them as coming from LDAP and \ + proceeding with login" ); defguard_user.from_ldap = true; defguard_user.save(pool).await?; @@ -58,17 +58,17 @@ pub(crate) async fn login_through_ldap( /// Convenience wrapper around [`ldap_update_users_state`] to update a single user. pub(crate) async fn ldap_update_user_state(user: &mut User, pool: &PgPool) { let vec = vec![user]; - ldap_update_users_state(vec, pool).await; + Box::pin(ldap_update_users_state(vec, pool)).await; } /// See the [`LDAPConnection::update_users_state`] function for details. pub(crate) async fn ldap_update_users_state(users: Vec<&mut User>, pool: &PgPool) { - let _: Result<(), LdapError> = with_ldap_status(pool, async { + let _ = Box::pin(with_ldap_status(pool, async { debug!("Updating users state in LDAP"); let mut ldap_connection = LDAPConnection::create().await?; ldap_connection.update_users_state(users, pool).await?; Ok(()) - }) + })) .await; } @@ -80,7 +80,10 @@ pub(crate) async fn ldap_add_user(user: &mut User, password: Option<&str>, p let _: Result<(), LdapError> = with_ldap_status(pool, async { debug!("Creating user {user} in LDAP"); if !user.ldap_sync_allowed(pool).await? { - debug!("User {user} is not allowed to be synced to LDAP as he is not in the specified sync groups, skipping"); + debug!( + "User {user} is not allowed to be synced to LDAP as he is not in the specified \ + sync groups, skipping" + ); return Ok(()); } let mut ldap_connection = LDAPConnection::create().await?; @@ -90,7 +93,7 @@ pub(crate) async fn ldap_add_user(user: &mut User, password: Option<&str>, p } match ldap_connection.add_user(user, password, pool).await { Ok(()) => Ok(()), - // this user might exist in LDAP, just try to set the password + // This user might exist in LDAP, just try to set the password. Err(err) => { warn!("There was an error while trying to create the user {user} in LDAP: {err}"); debug!( @@ -115,33 +118,40 @@ pub(crate) async fn ldap_add_user(user: &mut User, password: Option<&str>, p /// his RDN in Defguard needs updating. Fails and sets the sync status to desynced /// if the user does not exist in LDAP despite updating his state. /// -/// Warning: This function does not check if the user is allowed to be synced to LDAP. You must -/// do that manually before calling this function. For example, by calling the [`User::ldap_sync_allowed`] method on the user. +/// Warning: This function does not check if the user is allowed to be synced to LDAP. You must do +/// that manually before calling this function. For example, by calling the +/// [`User::ldap_sync_allowed`] method on the user. pub(crate) async fn ldap_handle_user_modify( old_username: &str, current_user: &mut User, pool: &PgPool, ) { - let _: Result<(), LdapError> = with_ldap_status(pool, async { + let _: Result<(), LdapError> = Box::pin(with_ldap_status(pool, async { debug!("Handling user modify for {old_username} in LDAP"); let mut ldap_connection = LDAPConnection::create().await?; - if !ldap_connection.user_exists(current_user).await? { - debug!("User {current_user} doesn't exist in LDAP, updating his state first as it may be stale"); - ldap_connection.update_users_state(vec![current_user], pool).await?; - } else { + if ldap_connection.user_exists(current_user).await? { debug!("User {current_user} exists in LDAP, modifying it"); + } else { + debug!( + "User {current_user} doesn't exist in LDAP, updating his state first as it may be \ + stale" + ); + ldap_connection + .update_users_state(vec![current_user], pool) + .await?; } ldap_connection .modify_user(old_username, current_user) .await - }) + })) .await; } /// Deletes single user from LDAP. Convenience wrapper around [`ldap_delete_users`]. /// -/// WARNING: You must check whether the user should be deleted from LDAP or not before calling this function. +/// WARNING: You must check whether the user should be deleted from LDAP or not before calling this +/// function. /// For example, by calling the [`User::ldap_sync_allowed`] method on the user. // // The mentioned method can't be called here since the user is already dropped from the database @@ -151,7 +161,8 @@ pub(crate) async fn ldap_delete_user(user: &User, pool: &PgPool) { /// Deletes multiple users from LDAP. /// -/// WARNING: You must check whether the users should be deleted from LDAP or not before calling this function. +/// WARNING: You must check whether the users should be deleted from LDAP or not before calling +/// this function. /// For example, by calling the [`User::ldap_sync_allowed`] method on each user. // // The mentioned method can't be called here since the user is already dropped from the database @@ -180,10 +191,11 @@ pub(crate) async fn ldap_remove_user_from_groups( ldap_remove_users_from_groups(map, pool).await; } -/// Add singular user to multiple groups in ldap. Convenience wrapper around [`ldap_add_users_to_groups`]. +/// Add singular user to multiple groups in LDAP. Convenience wrapper around +/// [`ldap_add_users_to_groups`]. pub(crate) async fn ldap_add_user_to_groups(user: &User, groups: HashSet<&str>, pool: &PgPool) { let map = HashMap::from([(user, groups)]); - ldap_add_users_to_groups(map, pool).await + ldap_add_users_to_groups(map, pool).await; } /// Bulk add users to groups in ldap. @@ -194,14 +206,20 @@ pub(crate) async fn ldap_add_users_to_groups( let _: Result<(), LdapError> = with_ldap_status(pool, async { let mut ldap_connection = LDAPConnection::create().await?; let sync_groups = ldap_connection.config.ldap_sync_groups.clone(); - let sync_groups_lookup = sync_groups.iter().map(|s| s.as_str()).collect::>(); + let sync_groups_lookup = sync_groups + .iter() + .map(String::as_str) + .collect::>(); for (user, groups) in user_groups { let adding_to_sync_groups = groups .iter() .any(|group| sync_groups_lookup.contains(*group)); if !user.ldap_sync_allowed(pool).await? && !adding_to_sync_groups { - debug!("User {user} is not allowed to be synced to LDAP as he is not in the specified sync groups, skipping"); + debug!( + "User {user} is not allowed to be synced to LDAP as he is not in the \ + specified sync groups, skipping" + ); continue; } @@ -215,7 +233,7 @@ pub(crate) async fn ldap_add_users_to_groups( .await; } -/// Bulk remove users from groups in ldap. +/// Bulk remove users from groups in LDAP. pub(crate) async fn ldap_remove_users_from_groups( user_groups: HashMap<&User, HashSet<&str>>, pool: &PgPool, @@ -223,14 +241,20 @@ pub(crate) async fn ldap_remove_users_from_groups( let _: Result<(), LdapError> = with_ldap_status(pool, async { let mut ldap_connection = LDAPConnection::create().await?; let sync_groups = ldap_connection.config.ldap_sync_groups.clone(); - let sync_groups_lookup = sync_groups.iter().map(|s| s.as_str()).collect::>(); + let sync_groups_lookup = sync_groups + .iter() + .map(String::as_str) + .collect::>(); for (user, groups) in user_groups { let removing_from_sync_groups = groups .iter() .any(|group| sync_groups_lookup.contains(*group)); if !user.ldap_sync_allowed(pool).await? && !removing_from_sync_groups { - debug!("User {user} is not allowed to be synced to LDAP as he is not in the specified sync groups, skipping"); + debug!( + "User {user} is not allowed to be synced to LDAP as he is not in the + specified sync groups, skipping" + ); continue; } for group in groups { @@ -251,31 +275,31 @@ pub(crate) async fn ldap_change_password(user: &mut User, password: &str, po let _: Result<(), LdapError> = with_ldap_status(pool, async { debug!("Changing password for user {user} in LDAP"); if !user.ldap_sync_allowed(pool).await? { - debug!("User {user} is not allowed to be synced to LDAP as he is not in the specified sync groups, skipping"); + debug!( + "User {user} is not allowed to be synced to LDAP as he is not in the specified + sync groups, skipping" + ); return Ok(()); } let mut ldap_connection = LDAPConnection::create().await?; - if !ldap_connection.user_exists(user).await? { + if ldap_connection.user_exists(user).await? { + debug!("User {user} exists in LDAP, changing password"); + ldap_connection.set_password(user, password).await?; + debug!( + "Password changed for user {user} in LDAP, marking the LDAP password as set in + Defguard" + ); + user.ldap_pass_randomized = false; + user.save(pool).await?; + debug!("LDAP password state updated in Defguard for user {user}"); + } else { debug!("User {user} doesn't exist in LDAP, creating it with the provided password"); let user_groups = user.member_of_names(pool).await?; ldap_connection.add_user(user, Some(password), pool).await?; for group in user_groups { ldap_connection.add_user_to_group(user, &group).await?; } - debug!("User {user} created in LDAP with the provided password"); - } else { - debug!("User {user} exists in LDAP, changing password"); - ldap_connection - .set_password(user, password) - .await?; - debug!( - "Password changed for user {user} in LDAP, marking the LDAP password as set in Defguard" - ); - user.ldap_pass_randomized = false; - user.save(pool).await?; - debug!( - "LDAP password state updated in Defguard for user {user}" - ); + debug!("User {user} created in LDAP with the provided password"); } Ok(()) diff --git a/crates/defguard_core/src/enterprise/license.rs b/crates/defguard_core/src/enterprise/license.rs index 17b6a8da32..420bdac777 100644 --- a/crates/defguard_core/src/enterprise/license.rs +++ b/crates/defguard_core/src/enterprise/license.rs @@ -4,7 +4,10 @@ use anyhow::Result; use base64::prelude::*; use chrono::{DateTime, TimeDelta, Utc}; use humantime::format_duration; -use pgp::{Deserializable, SignedPublicKey, StandaloneSignature, types::PublicKeyTrait}; +use pgp::{ + composed::{Deserializable, SignedPublicKey, StandaloneSignature}, + types::{KeyDetails, PublicKeyTrait}, +}; use prost::Message; use sqlx::{PgPool, error::Error as SqlxError}; use thiserror::Error; @@ -205,6 +208,7 @@ pub struct License { pub subscription: bool, pub valid_until: Option>, pub limits: Option, + pub version_date_limit: Option>, } impl License { @@ -214,12 +218,14 @@ impl License { subscription: bool, valid_until: Option>, limits: Option, + version_date_limit: Option>, ) -> Self { Self { customer_id, subscription, valid_until, limits, + version_date_limit, } } @@ -295,11 +301,17 @@ impl License { None => None, }; + let version_date_limit = match metadata.version_date_limit { + Some(date) => DateTime::from_timestamp(date, 0), + None => None, + }; + let license = License::new( metadata.customer_id, metadata.subscription, valid_until, metadata.limits, + version_date_limit, ); if license.requires_renewal() { @@ -732,6 +744,7 @@ mod test { false, Some(Utc::now() - TimeDelta::days(1)), None, + None, ); assert!(validate_license(Some(&license), &counts).is_err()); @@ -741,11 +754,12 @@ mod test { false, Some(Utc::now() + TimeDelta::days(1)), None, + None, ); assert!(validate_license(Some(&license), &counts).is_ok()); // No expiry date, non-subscription license - let license = License::new("test".to_string(), false, None, None); + let license = License::new("test".to_string(), false, None, None, None); assert!(validate_license(Some(&license), &counts).is_ok()); // One day past the maximum overdue date @@ -754,6 +768,7 @@ mod test { true, Some(Utc::now() - MAX_OVERDUE_TIME - TimeDelta::days(1)), None, + None, ); assert!(validate_license(Some(&license), &counts).is_err()); @@ -763,10 +778,11 @@ mod test { true, Some(Utc::now() - MAX_OVERDUE_TIME + TimeDelta::days(1)), None, + None, ); assert!(validate_license(Some(&license), &counts).is_ok()); - let counts = Counts::new(5, 5, 5); + let counts = Counts::new(5, 5, 5, 5); // Over object count limits let license = License::new( @@ -777,7 +793,9 @@ mod test { users: 1, devices: 1, locations: 1, + network_devices: Some(1), }), + None, ); assert!(validate_license(Some(&license), &counts).is_err()); @@ -790,7 +808,9 @@ mod test { users: 10, devices: 10, locations: 10, + network_devices: Some(10), }), + None, ); assert!(validate_license(Some(&license), &counts).is_ok()); } diff --git a/crates/defguard_core/src/enterprise/limits.rs b/crates/defguard_core/src/enterprise/limits.rs index 0f738ec210..51a006a682 100644 --- a/crates/defguard_core/src/enterprise/limits.rs +++ b/crates/defguard_core/src/enterprise/limits.rs @@ -3,18 +3,20 @@ use sqlx::{PgPool, error::Error as SqlxError, query}; use super::license::License; #[cfg(test)] use super::license::get_cached_license; -use crate::global_value; +use crate::{global_value, grpc::proto::enterprise::license::LicenseLimits}; // Limits for free users pub const DEFAULT_USERS_LIMIT: u32 = 5; pub const DEFAULT_DEVICES_LIMIT: u32 = 10; pub const DEFAULT_LOCATIONS_LIMIT: u32 = 1; +pub const DEFAULT_NETWORK_DEVICES_LIMIT: u32 = 10; #[derive(Debug)] #[cfg_attr(test, derive(Clone))] pub struct Counts { user: u32, - device: u32, + user_device: u32, + network_device: u32, wireguard_network: u32, } @@ -27,7 +29,8 @@ pub async fn update_counts<'e, E: sqlx::PgExecutor<'e>>(executor: E) -> Result<( let result = query!( "SELECT \ (SELECT count(*) FROM \"user\") \"users!\", \ - (SELECT count(*) FROM device) \"devices!\", \ + (SELECT count(*) FROM device WHERE device_type = 'user') \"user_devices!\", \ + (SELECT count(*) FROM device WHERE device_type = 'network') \"network_devices!\", (SELECT count(*) FROM wireguard_network) \"wireguard_networks!\" " ) @@ -40,8 +43,12 @@ pub async fn update_counts<'e, E: sqlx::PgExecutor<'e>>(executor: E) -> Result<( .users .try_into() .expect("user count should never be negative"), - device: result - .devices + user_device: result + .user_devices + .try_into() + .expect("device count should never be negative"), + network_device: result + .network_devices .try_into() .expect("device count should never be negative"), wireguard_network: result @@ -68,17 +75,24 @@ impl Counts { pub(crate) const fn default() -> Self { Self { user: 0, - device: 0, + user_device: 0, wireguard_network: 0, + network_device: 0, } } #[cfg(test)] - pub(crate) fn new(user: u32, device: u32, wireguard_network: u32) -> Self { + pub(crate) fn new( + user: u32, + user_device: u32, + wireguard_network: u32, + network_device: u32, + ) -> Self { Self { user, - device, + user_device, wireguard_network, + network_device, } } @@ -98,8 +112,19 @@ impl Counts { else { debug!("Cached license not found. Using default limits for validation..."); self.user > DEFAULT_USERS_LIMIT - || self.device > DEFAULT_DEVICES_LIMIT + || self.user_device > DEFAULT_DEVICES_LIMIT || self.wireguard_network > DEFAULT_LOCATIONS_LIMIT + || self.network_device > DEFAULT_NETWORK_DEVICES_LIMIT + } + } + + // New licenses have a network device limit field, this function handles backwards compatibility + // If no such field is present = old behavior (user devices + network devices <= devices limit) + // If field is present, check user devices and network devices separately + fn is_over_device_limit(&self, limits: &LicenseLimits) -> bool { + match limits.network_devices { + Some(devices) => self.user_device > limits.devices || self.network_device > devices, + None => self.user_device + self.network_device > limits.devices, } } @@ -108,7 +133,7 @@ impl Counts { match limits { Some(limits) => { self.user > limits.users - || self.device > limits.devices + || self.is_over_device_limit(limits) || self.wireguard_network > limits.locations } // unlimited license @@ -120,8 +145,9 @@ impl Counts { pub(crate) fn needs_enterprise_license(&self) -> bool { debug!("Checking if current object counts ({self:?}) exceed default limits"); self.user > DEFAULT_USERS_LIMIT - || self.device > DEFAULT_DEVICES_LIMIT + || self.user_device > DEFAULT_DEVICES_LIMIT || self.wireguard_network > DEFAULT_LOCATIONS_LIMIT + || self.network_device > DEFAULT_NETWORK_DEVICES_LIMIT } pub(crate) fn get_exceeded_limits(&self, license: Option<&License>) -> LimitsExceeded { @@ -129,21 +155,31 @@ impl Counts { if let Some(limits) = &license.limits { LimitsExceeded { user: self.user > limits.users, - device: self.device > limits.devices, + device: if limits.network_devices.is_some() { + self.user_device > limits.devices + } else { + self.user_device + self.network_device > limits.devices + }, wireguard_network: self.wireguard_network > limits.locations, + network_device: match limits.network_devices { + Some(devices) => self.network_device > devices, + None => false, + }, } } else { LimitsExceeded { user: false, device: false, wireguard_network: false, + network_device: false, } } } else { LimitsExceeded { user: self.user > DEFAULT_DEVICES_LIMIT, - device: self.device > DEFAULT_DEVICES_LIMIT, + device: self.user_device > DEFAULT_DEVICES_LIMIT, wireguard_network: self.wireguard_network > DEFAULT_LOCATIONS_LIMIT, + network_device: self.network_device > DEFAULT_NETWORK_DEVICES_LIMIT, } } } @@ -155,12 +191,13 @@ pub(crate) struct LimitsExceeded { pub user: bool, pub device: bool, pub wireguard_network: bool, + pub network_device: bool, } /// Returns true if any of the limits has been exceeded. impl LimitsExceeded { pub(crate) fn any(&self) -> bool { - self.user || self.device || self.wireguard_network + self.user || self.device || self.wireguard_network || self.network_device } } @@ -174,12 +211,61 @@ mod test { grpc::proto::enterprise::license::LicenseLimits, }; + #[test] + fn test_network_device_limit_old_license() { + let limits = LicenseLimits { + users: 10, + devices: 20, + locations: 5, + network_devices: None, + }; + let counts = Counts { + user: 5, + user_device: 15, + wireguard_network: 3, + network_device: 6, + }; + assert!(counts.is_over_device_limit(&limits)); + + let counts = Counts { + user: 5, + user_device: 10, + wireguard_network: 3, + network_device: 5, + }; + assert!(!counts.is_over_device_limit(&limits)); + + let limits = LicenseLimits { + users: 10, + devices: 20, + locations: 5, + network_devices: Some(10), + }; + + let counts = Counts { + user: 5, + user_device: 15, + wireguard_network: 3, + network_device: 6, + }; + assert!(!counts.is_over_device_limit(&limits)); + + let counts = Counts { + user: 5, + user_device: 15, + wireguard_network: 3, + network_device: 11, + }; + assert!(counts.is_over_device_limit(&limits)); + } + #[test] fn test_counts() { let counts = Counts { user: 1, - device: 2, + user_device: 2, wireguard_network: 3, + network_device: 4, }; set_counts(counts); @@ -187,7 +273,7 @@ mod test { let counts = get_counts(); assert_eq!(counts.user, 1); - assert_eq!(counts.device, 2); + assert_eq!(counts.user_device, 2); assert_eq!(counts.wireguard_network, 3); } @@ -197,8 +283,9 @@ mod test { { let counts = Counts { user: DEFAULT_USERS_LIMIT + 1, - device: 1, + user_device: 1, wireguard_network: 1, + network_device: 1, }; set_counts(counts); let counts = get_counts(); @@ -209,8 +296,9 @@ mod test { { let counts = Counts { user: 1, - device: DEFAULT_DEVICES_LIMIT + 1, + user_device: DEFAULT_DEVICES_LIMIT + 1, wireguard_network: 1, + network_device: 1, }; set_counts(counts); let counts = get_counts(); @@ -221,8 +309,9 @@ mod test { { let counts = Counts { user: 1, - device: 1, + user_device: 1, wireguard_network: DEFAULT_LOCATIONS_LIMIT + 1, + network_device: 1, }; set_counts(counts); let counts = get_counts(); @@ -233,8 +322,9 @@ mod test { { let counts = Counts { user: 1, - device: 1, + user_device: 1, wireguard_network: 1, + network_device: 1, }; set_counts(counts); let counts = get_counts(); @@ -245,8 +335,9 @@ mod test { { let counts = Counts { user: DEFAULT_USERS_LIMIT + 1, - device: DEFAULT_DEVICES_LIMIT, + user_device: DEFAULT_DEVICES_LIMIT, wireguard_network: DEFAULT_LOCATIONS_LIMIT, + network_device: 1, }; set_counts(counts); let counts = get_counts(); @@ -259,17 +350,20 @@ mod test { let users_limit = 15; let devices_limit = 35; let locations_limit = 4; + let network_devices_limit = 10; let limits = LicenseLimits { users: users_limit, devices: devices_limit, locations: locations_limit, + network_devices: Some(network_devices_limit), }; let license = License::new( "test".to_string(), true, Some(Utc::now() + TimeDelta::days(1)), Some(limits), + None, ); set_cached_license(Some(license)); @@ -277,8 +371,9 @@ mod test { { let counts = Counts { user: users_limit + 1, - device: 1, + user_device: 1, wireguard_network: 1, + network_device: 1, }; set_counts(counts); let counts = get_counts(); @@ -289,8 +384,9 @@ mod test { { let counts = Counts { user: 1, - device: devices_limit + 1, + user_device: devices_limit + 1, wireguard_network: 1, + network_device: 1, }; set_counts(counts); let counts = get_counts(); @@ -301,8 +397,9 @@ mod test { { let counts = Counts { user: 1, - device: 1, + user_device: 1, wireguard_network: locations_limit + 1, + network_device: 1, }; set_counts(counts); let counts = get_counts(); @@ -313,8 +410,9 @@ mod test { { let counts = Counts { user: users_limit, - device: devices_limit, + user_device: devices_limit, wireguard_network: locations_limit, + network_device: network_devices_limit, }; set_counts(counts); let counts = get_counts(); @@ -325,8 +423,9 @@ mod test { { let counts = Counts { user: users_limit + 1, - device: devices_limit + 1, + user_device: devices_limit + 1, wireguard_network: locations_limit + 1, + network_device: network_devices_limit + 1, }; set_counts(counts); let counts = get_counts(); @@ -341,6 +440,7 @@ mod test { true, Some(Utc::now() + TimeDelta::days(1)), None, + None, ); set_cached_license(Some(license)); @@ -348,8 +448,9 @@ mod test { { let counts = Counts { user: u32::MAX, - device: u32::MAX, + user_device: u32::MAX, wireguard_network: u32::MAX, + network_device: u32::MAX, }; set_counts(counts); let counts = get_counts(); @@ -362,35 +463,41 @@ mod test { let exceed_user = DEFAULT_DEVICES_LIMIT + 5; let exceed_device = DEFAULT_DEVICES_LIMIT + 5; let exceed_wireguard_network = DEFAULT_LOCATIONS_LIMIT + 5; + let exceed_network_device = DEFAULT_NETWORK_DEVICES_LIMIT + 5; let counts = Counts { user: exceed_user, - device: 0, + user_device: 0, wireguard_network: 0, + network_device: 0, }; set_counts(counts); let exceeded = get_counts().get_exceeded_limits(None); assert!(exceeded.user); assert!(!exceeded.device); assert!(!exceeded.wireguard_network); + assert!(!exceeded.network_device); assert!(exceeded.any()); let counts = Counts { user: 0, - device: exceed_device, + user_device: exceed_device, wireguard_network: 0, + network_device: 0, }; set_counts(counts); let exceeded = get_counts().get_exceeded_limits(None); assert!(!exceeded.user); assert!(exceeded.device); assert!(!exceeded.wireguard_network); + assert!(!exceeded.network_device); assert!(exceeded.any()); let counts = Counts { user: 0, - device: 0, + user_device: 0, wireguard_network: exceed_wireguard_network, + network_device: 0, }; set_counts(counts); let exceeded = get_counts().get_exceeded_limits(None); @@ -401,14 +508,31 @@ mod test { let counts = Counts { user: 0, - device: 0, + user_device: 0, + wireguard_network: 0, + network_device: exceed_network_device, + }; + + set_counts(counts); + let exceeded = get_counts().get_exceeded_limits(None); + assert!(!exceeded.user); + assert!(!exceeded.device); + assert!(!exceeded.wireguard_network); + assert!(exceeded.network_device); + assert!(exceeded.any()); + + let counts = Counts { + user: 0, + user_device: 0, wireguard_network: 0, + network_device: 0, }; set_counts(counts); let exceeded = get_counts().get_exceeded_limits(None); assert!(!exceeded.user); assert!(!exceeded.device); assert!(!exceeded.wireguard_network); + assert!(!exceeded.network_device); assert!(!exceeded.any()); let license = License::new( @@ -419,18 +543,22 @@ mod test { users: 2, devices: 2, locations: 2, + network_devices: Some(2), }), + None, ); let counts = Counts { user: 3, - device: 3, + user_device: 3, wireguard_network: 3, + network_device: 3, }; set_counts(counts); let exceeded = get_counts().get_exceeded_limits(Some(&license)); assert!(exceeded.user); assert!(exceeded.device); assert!(exceeded.wireguard_network); + assert!(exceeded.network_device); assert!(exceeded.any()); let license = License::new( @@ -438,17 +566,20 @@ mod test { true, Some(Utc::now() + TimeDelta::days(1)), None, + None, ); let counts = Counts { user: 300, - device: 300, + user_device: 300, wireguard_network: 300, + network_device: 300, }; set_counts(counts); let exceeded = get_counts().get_exceeded_limits(Some(&license)); assert!(!exceeded.user); assert!(!exceeded.device); assert!(!exceeded.wireguard_network); + assert!(!exceeded.network_device); assert!(!exceeded.any()); } } diff --git a/crates/defguard_core/src/enterprise/proto/license.proto b/crates/defguard_core/src/enterprise/proto/license.proto index 57b3eb0cd3..098548a450 100644 --- a/crates/defguard_core/src/enterprise/proto/license.proto +++ b/crates/defguard_core/src/enterprise/proto/license.proto @@ -5,6 +5,7 @@ message LicenseLimits { uint32 users = 1; uint32 devices = 2; uint32 locations = 3; + optional uint32 network_devices = 4; } message LicenseMetadata { @@ -12,6 +13,7 @@ message LicenseMetadata { bool subscription = 2; optional int64 valid_until = 3; LicenseLimits limits = 4; + optional int64 version_date_limit = 5; } message LicenseKey { diff --git a/crates/defguard_core/src/enterprise/snat/handlers.rs b/crates/defguard_core/src/enterprise/snat/handlers.rs index 7a2c360f4a..3b9e71adbf 100644 --- a/crates/defguard_core/src/enterprise/snat/handlers.rs +++ b/crates/defguard_core/src/enterprise/snat/handlers.rs @@ -1,3 +1,5 @@ +use std::net::IpAddr; + use axum::{ Json, extract::{Path, State}, @@ -5,7 +7,6 @@ use axum::{ use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::net::IpAddr; use utoipa::ToSchema; use crate::{ @@ -21,6 +22,11 @@ use crate::{ }; /// List all SNAT bindings for a WireGuard location +/// +/// # Returns +/// - `Vec>` object +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/network/{location_id}/snat", @@ -74,6 +80,13 @@ pub struct NewUserSnatBinding { } /// Create a new SNAT binding for a user in a WireGuard location +/// +/// Create snat binding basing on `NewUserSnatBinding` object. +/// +/// # Returns +/// - `UserSnatBinding` object +/// +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/network/{location_id}/snat", @@ -164,6 +177,13 @@ pub struct EditUserSnatBinding { } /// Modify an existing SNAT binding for a user in a WireGuard location +/// +/// Modify an **existing** SNAT binding basing on `EditUserSnatBinding` object. +/// +/// # Returns +/// - `UserSnatBinding` object +/// +/// - `WebError` if error occurs #[utoipa::path( put, path = "/api/v1/network/{location_id}/snat/{user_id}", @@ -252,6 +272,13 @@ pub async fn modify_snat_binding( } /// Delete an existing SNAT binding for a user in a WireGuard location +/// +/// Delete an existing SNAT binding basing on `location_id` and `user_id`. +/// +/// # Returns +/// - empty JSON +/// +/// - `WebError` if error occurs #[utoipa::path( delete, path = "/api/v1/network/{location_id}/snat/{user_id}", diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index 162c04ab1a..cea003dcc2 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -2,6 +2,7 @@ use axum::http::StatusCode; use sqlx::error::Error as SqlxError; use thiserror::Error; use tokio::sync::mpsc::error::SendError; +use utoipa::ToSchema; use crate::{ auth::failed_login::FailedLoginError, @@ -14,12 +15,12 @@ use crate::{ firewall::FirewallError, ldap::error::LdapError, license::LicenseError, }, events::ApiEvent, - grpc::GatewayMapError, + grpc::gateway::map::GatewayMapError, templates::TemplateError, }; /// Represents kinds of error that occurred -#[derive(Debug, Error)] +#[derive(Debug, Error, ToSchema)] pub enum WebError { #[error("GRPC error: {0}")] Grpc(String), @@ -52,26 +53,34 @@ pub enum WebError { #[error("Public key already exists {0}")] PubkeyExists(String), #[error("HTTP error: {0}")] + #[schema(value_type=Object)] Http(StatusCode), #[error(transparent)] + #[schema(value_type=Object)] TooManyLoginAttempts(#[from] FailedLoginError), #[error("Bad request: {0}")] BadRequest(String), #[error(transparent)] + #[schema(value_type=Object)] TemplateError(#[from] TemplateError), #[error("Server config missing")] ServerConfigMissing, #[error("License error: {0}")] + #[schema(value_type=Object)] LicenseError(#[from] LicenseError), #[error("Failed to get client IP address")] ClientIpError, #[error("ACL error: {0}")] + #[schema(value_type=Object)] AclError(#[from] AclError), #[error("Firewall config error: {0}")] + #[schema(value_type=Object)] FirewallError(#[from] FirewallError), #[error("API event channel error: {0}")] + #[schema(value_type=Object)] ApiEventChannelError(#[from] SendError), #[error("Activity log stream error: {0}")] + #[schema(value_type=Object)] ActivityLogStreamError(#[from] ActivityLogStreamError), } @@ -140,9 +149,8 @@ impl From for WebError { | WireguardNetworkError::Unexpected(_) | WireguardNetworkError::DeviceError(_) | WireguardNetworkError::DeviceNotAllowed(_) - | WireguardNetworkError::FirewallError(_) => { - Self::Http(StatusCode::INTERNAL_SERVER_ERROR) - } + | WireguardNetworkError::FirewallError(_) + | WireguardNetworkError::TokenError(_) => Self::Http(StatusCode::INTERNAL_SERVER_ERROR), } } } diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index 9b5b1d571b..59c3d9ffcf 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -30,6 +30,7 @@ pub struct ApiRequestContext { } impl ApiRequestContext { + #[must_use] pub fn new(user_id: Id, username: String, ip: IpAddr, device: String) -> Self { let timestamp = Utc::now().naive_utc(); Self { @@ -57,6 +58,7 @@ pub struct GrpcRequestContext { } impl GrpcRequestContext { + #[must_use] pub fn new( user_id: Id, username: String, @@ -335,6 +337,7 @@ pub struct BidiRequestContext { } impl BidiRequestContext { + #[must_use] pub fn new(user_id: Id, username: String, ip: IpAddr, device_name: String) -> Self { let timestamp = Utc::now().naive_utc(); Self { @@ -389,6 +392,10 @@ impl Serialize for ClientMFAMethod { MfaMethod::Totp => serializer.serialize_unit_variant("MfaMethod", 0, "Totp"), MfaMethod::Email => serializer.serialize_unit_variant("MfaMethod", 1, "Email"), MfaMethod::Oidc => serializer.serialize_unit_variant("MfaMethod", 2, "Oidc"), + MfaMethod::Biometric => serializer.serialize_unit_variant("MfaMethod", 3, "Biometric"), + MfaMethod::MobileApprove => { + serializer.serialize_unit_variant("MfaMethod", 4, "MobileApprove") + } } } } @@ -421,6 +428,7 @@ pub struct InternalEventContext { } impl InternalEventContext { + #[must_use] pub fn new(user_id: Id, username: String, ip: IpAddr, device: Device) -> Self { let timestamp = Utc::now().naive_utc(); Self { diff --git a/crates/defguard_core/src/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/grpc/client_mfa.rs similarity index 69% rename from crates/defguard_core/src/grpc/desktop_client_mfa.rs rename to crates/defguard_core/src/grpc/client_mfa.rs index ddcf0c3c7f..f7ac11e548 100644 --- a/crates/defguard_core/src/grpc/desktop_client_mfa.rs +++ b/crates/defguard_core/src/grpc/client_mfa.rs @@ -18,13 +18,17 @@ use crate::{ db::{ Device, GatewayEvent, Id, User, UserInfo, WireguardNetwork, models::{ + biometric_auth::{BiometricAuth, BiometricChallenge}, device::{DeviceInfo, DeviceNetworkInfo, WireguardNetworkDevice}, wireguard::LocationMfaMode, }, }, enterprise::{db::models::openid_provider::OpenIdProvider, is_enterprise_enabled}, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, - grpc::utils::parse_client_info, + grpc::{ + proto::proxy::{ClientMfaTokenValidationRequest, ClientMfaTokenValidationResponse}, + utils::parse_client_info, + }, handlers::mail::send_email_mfa_code_email, mail::Mail, }; @@ -50,6 +54,7 @@ pub(crate) struct ClientLoginSession { pub(crate) device: Device, pub(crate) user: User, pub(crate) openid_auth_completed: bool, + pub(crate) biometric_challenge: Option, } pub(crate) struct ClientMfaServer { @@ -104,6 +109,19 @@ impl ClientMfaServer { Ok(self.bidi_event_tx.send(event)?) } + /// Allows proxy to verify if token is valid and active + #[instrument(skip_all)] + pub(crate) async fn validate_mfa_token( + &mut self, + request: ClientMfaTokenValidationRequest, + ) -> Result { + let pubkey = Self::parse_token(&request.token)?; + let session_active = self.sessions.contains_key(&pubkey); + Ok(ClientMfaTokenValidationResponse { + token_valid: session_active, + }) + } + #[instrument(skip_all)] pub async fn start_client_mfa_login( &mut self, @@ -161,16 +179,22 @@ impl ClientMfaServer { match (&location.location_mfa_mode, selected_method) { // MFA enabled status is already verified (LocationMfaMode::Disabled, _) => unreachable!(), - (LocationMfaMode::Internal, MfaMethod::Totp) - | (LocationMfaMode::Internal, MfaMethod::Email) => { - debug!("Location uses internal MFA. Selected method: {selected_method}") + ( + LocationMfaMode::Internal, + MfaMethod::Totp + | MfaMethod::Email + | MfaMethod::Biometric + | MfaMethod::MobileApprove, + ) => { + debug!("Location uses internal MFA. Selected method: {selected_method}"); } (LocationMfaMode::External, MfaMethod::Oidc) => { - debug!("Location uses external MFA. Selected method: {selected_method}") + debug!("Location uses external MFA. Selected method: {selected_method}"); } _ => { error!( - "Selected MFA method ({selected_method}) is not supported by location {location} which uses {}", + "Selected MFA method ({selected_method}) is not supported by location \ + {location} which uses {}", location.location_mfa_mode ); @@ -180,8 +204,33 @@ impl ClientMfaServer { } } + let mut selected_mobile_auth: Option> = None; + // check if selected method is configured match selected_method { + MfaMethod::Biometric => { + if let Some(found) = BiometricAuth::find_by_device_id(&self.pool, device.id) + .await + .map_err(|_| Status::internal("unexpected_error"))? + { + selected_mobile_auth = Some(found); + } else { + return Err(Status::invalid_argument( + "Select MFA method not available for the device.", + )); + } + } + // just check if the account has any devices with biometric auth present + MfaMethod::MobileApprove => { + let result = BiometricAuth::find_by_user_id(&self.pool, user.id) + .await + .map_err(|_| Status::internal("unexpected error"))?; + if result.is_empty() { + return Err(Status::invalid_argument( + "selected MFA method not available", + )); + } + } MfaMethod::Totp => { if !user.totp_enabled { error!("TOTP not enabled for user {}", user.username); @@ -228,7 +277,7 @@ impl ClientMfaServer { )); } } - }; + } // generate auth token let token = Self::generate_token(&request.pubkey)?; @@ -238,6 +287,29 @@ impl ClientMfaServer { user.username, location.name ); + let biometric_challenge: Option = match selected_method { + MfaMethod::Biometric => match selected_mobile_auth { + Some(mobile_auth) => { + let challenge = BiometricChallenge::new_with_owner(&mobile_auth.pub_key).map_err(|e| { + error!( + "Start biometric mfa failed ! Challenge creation failed ! Reason: {e}" + ); + Status::invalid_argument("Invalid public key") + })?; + Some(challenge) + } + None => { + return Err(Status::internal("unexpected error")); + } + }, + MfaMethod::MobileApprove => Some(BiometricChallenge::new()), + _ => None, + }; + + let response_challenge = biometric_challenge + .as_ref() + .map(|challenge| challenge.challenge.clone()); + // store login session self.sessions.insert( request.pubkey, @@ -247,10 +319,14 @@ impl ClientMfaServer { device, user, openid_auth_completed: false, + biometric_challenge, }, ); - Ok(ClientMfaStartResponse { token }) + Ok(ClientMfaStartResponse { + token, + challenge: response_challenge, + }) } /// Checks if given user is allowed to access a location @@ -312,6 +388,7 @@ impl ClientMfaServer { location, user, openid_auth_completed, + biometric_challenge, } = session; // Prepare event context @@ -325,6 +402,83 @@ impl ClientMfaServer { // validate code match method { + MfaMethod::MobileApprove => { + let challenge = biometric_challenge.as_ref().ok_or_else(|| { + error!("Challenge not found in MFA session."); + Status::invalid_argument("Challenge not found in session") + })?; + let signature = request.code.ok_or_else(|| { + error!("Signed challenge not found in request"); + Status::invalid_argument("Signature not found in request") + })?; + let auth_device_pub_key = request.auth_pub_key.ok_or_else(|| { + Status::invalid_argument("Authorization device key missing in request") + })?; + if !BiometricAuth::verify_owner(&self.pool, user.id, &auth_device_pub_key) + .await + .map_err(|_| Status::internal("unexpected error"))? + { + return Err(Status::invalid_argument("Arguments invalid")); + } + match challenge.verify(signature.as_str(), Some(auth_device_pub_key)) { + Ok(()) => { + debug!("Signature verified successfully."); + } + Err(err) => { + error!( + "Verification of challenge for device {} failed; reason {err}", + &device.name + ); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + location: location.clone(), + device: device.clone(), + method: *method, + message: "Signed challenge rejected".to_string(), + }, + )), + })?; + return Err(Status::unauthenticated("unauthorized")); + } + } + } + MfaMethod::Biometric => { + let challenge = biometric_challenge.as_ref().ok_or_else(|| { + error!("Challenge not found in MFA session !"); + Status::internal("Challenge not found in MFA session") + })?; + let signed_challenge = request.code.ok_or_else(|| { + error!("Signed challenge not found in request"); + Status::invalid_argument("Challenge not found in request") + })?; + match challenge.verify(signed_challenge.as_str(), None) { + // verification passed + Ok(()) => { + debug!("Signature verified successfully."); + } + // challenge rejected + Err(e) => { + error!( + "Verification of challenge for device {0} failed ! Reason {e}", + &device.name + ); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + location: location.clone(), + device: device.clone(), + method: *method, + message: "Signed challenge rejected".to_string(), + }, + )), + })?; + return Err(Status::unauthenticated("unauthorized")); + } + } + } MfaMethod::Totp => { let code = if let Some(code) = request.code { code.to_string() @@ -396,7 +550,8 @@ impl ClientMfaServer { MfaMethod::Oidc => { if !*openid_auth_completed { debug!( - "User {user} tried to finish OIDC MFA login but they haven't completed the OIDC authentication yet." + "User {user} tried to finish OIDC MFA login but they haven't completed \ + the OIDC authentication yet." ); self.emit_event(BidiStreamEvent { context, @@ -405,18 +560,20 @@ impl ClientMfaServer { location: location.clone(), device: device.clone(), method: *method, - message: "tried to finish OIDC MFA login but they haven't completed OIDC authentication yet".to_string() + message: "tried to finish OIDC MFA login but they haven't \ + completed OIDC authentication yet" + .to_string(), }, )), })?; return Err(Status::failed_precondition( "OIDC authentication not completed yet", )); - } else { - debug!( - "User {user} is trying to finish OIDC MFA login and the OIDC authentication has already been completed; proceeding." - ); } + debug!( + "User {user} is trying to finish OIDC MFA login and the OIDC authentication \ + has already been completed; proceeding." + ); } } @@ -469,8 +626,10 @@ impl ClientMfaServer { })?; info!( - "Desktop client login finished for {} at location {}", - user.username, location.name + "Desktop client login finished for {} at location {} with method {}", + user.username, + location.name, + method.as_str_name() ); self.emit_event(BidiStreamEvent { context, @@ -483,6 +642,14 @@ impl ClientMfaServer { )), })?; + let response = ClientMfaFinishResponse { + preshared_key: key.public, + token: match method { + MfaMethod::MobileApprove => Some(request.token.clone()), + _ => None, + }, + }; + // remove login session from map self.sessions.remove(&pubkey); @@ -492,8 +659,6 @@ impl ClientMfaServer { Status::internal("unexpected error") })?; - Ok(ClientMfaFinishResponse { - preshared_key: key.public, - }) + Ok(response) } } diff --git a/crates/defguard_core/src/grpc/enrollment.rs b/crates/defguard_core/src/grpc/enrollment.rs index 48a8dcddd5..4490c08582 100644 --- a/crates/defguard_core/src/grpc/enrollment.rs +++ b/crates/defguard_core/src/grpc/enrollment.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use sqlx::{PgPool, Transaction}; +use sqlx::{PgPool, Transaction, query_scalar}; use tokio::sync::{ broadcast::Sender, mpsc::{UnboundedSender, error::SendError}, @@ -18,8 +18,9 @@ use super::{ use crate::{ AsCsv, db::{ - Device, GatewayEvent, Id, Settings, User, WireguardNetwork, + Device, GatewayEvent, Id, MFAMethod, Settings, User, WireguardNetwork, models::{ + biometric_auth::BiometricAuth, device::{DeviceConfig, DeviceInfo, DeviceType}, enrollment::{ENROLLMENT_TOKEN_TYPE, Token, TokenError}, polling_token::PollingToken, @@ -33,10 +34,19 @@ use crate::{ }, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, EnrollmentEvent}, grpc::{ - proto::proxy::LocationMfaMode as ProtoLocationMfaMode, + proto::proxy::{ + CodeMfaSetupFinishRequest, CodeMfaSetupFinishResponse, CodeMfaSetupStartRequest, + CodeMfaSetupStartResponse, LocationMfaMode as ProtoLocationMfaMode, MfaMethod, + RegisterMobileAuthRequest, + }, utils::{build_device_config_response, new_polling_token, parse_client_info}, }, - handlers::{mail::send_new_device_added_email, user::check_password_strength}, + handlers::{ + mail::{ + send_email_mfa_activation_email, send_mfa_configured_email, send_new_device_added_email, + }, + user::check_password_strength, + }, headers::get_device_info, mail::Mail, server_config, @@ -206,7 +216,7 @@ impl EnrollmentServer { error!("Failed to get OpenID provider: {err}"); Status::internal(format!("unexpected error: {err}")) })?; - + let smtp_configured = settings.smtp_configured(); let instance_info = InstanceInfo::new( settings, &user.username, @@ -235,10 +245,7 @@ impl EnrollmentServer { let admin_info = admin.map(AdminInfo::from); debug!("Admin info {admin_info:?}"); - debug!( - "Creating enrollment start response for user {}({:?}).", - username, user_id, - ); + debug!("Creating enrollment start response for user {username}({user_id:?})."); let enterprise_settings = EnterpriseSettings::get(&mut *transaction) .await @@ -246,10 +253,22 @@ impl EnrollmentServer { error!("Failed to get enterprise settings: {err}"); Status::internal("unexpected error") })?; + // check if any locations enforce internal MFA + let instance_has_internal_mfa = query_scalar!( + "SELECT EXISTS( \ + SELECT 1 FROM wireguard_network \ + WHERE location_mfa_mode = 'internal'::location_mfa_mode \ + ) \"exists!\"" + ) + .fetch_one(&self.pool) + .await + .map_err(|_| Status::internal("Failed to read data".to_string()))?; let enrollment_settings = super::proto::proxy::EnrollmentSettings { vpn_setup_optional, + smtp_configured, only_client_activation: enterprise_settings.only_client_activation, admin_device_management: enterprise_settings.admin_device_management, + mfa_required: instance_has_internal_mfa, }; let response = super::proto::proxy::EnrollmentStartResponse { admin: admin_info, @@ -284,6 +303,46 @@ impl EnrollmentServer { } } + #[instrument(skip_all)] + pub async fn register_mobile_auth( + &self, + request: RegisterMobileAuthRequest, + ) -> Result<(), Status> { + debug!("Register mobile auth started"); + let enrollment = self.validate_session(Some(&request.token)).await?; + let user = enrollment.fetch_user(&self.pool).await?; + Device::validate_pubkey(&request.device_pub_key).map_err(|err| { + error!( + "Invalid public key {}, device won't be registered as mobile MFA auth for user {}\ + ({:?}): {err}", + request.device_pub_key, user.username, user.id + ); + Status::invalid_argument("invalid pubkey") + })?; + let Some(device) = Device::find_by_pubkey(&self.pool, &request.device_pub_key) + .await + .map_err(|err| { + error!("Failed to read devices from db: {err}"); + Status::internal("Something went wrong") + })? + else { + return Err(Status::invalid_argument( + "Device with given public key doesn't exist", + )); + }; + BiometricAuth::validate_pubkey(&request.device_pub_key)?; + let mobile_auth = BiometricAuth::new(device.id, request.auth_pub_key); + let _ = mobile_auth.save(&self.pool).await.map_err(|err| { + error!("Failed to save mobile auth into db: {err}"); + Status::internal("Failed to save results") + })?; + info!( + "User {}({}) registered mobile auth for device {}({})", + user.username, user.id, device.name, device.id + ); + Ok(()) + } + #[instrument(skip_all)] pub async fn activate_user( &self, @@ -353,7 +412,7 @@ impl EnrollmentServer { debug!("Retriving settings to send welcome email..."); let settings = Settings::get_current_settings(); - debug!("Successfully retrived settings."); + debug!("Settings successfully retrieved."); // send welcome email debug!("Try to send welcome email..."); @@ -802,7 +861,7 @@ impl EnrollmentServer { request: ExistingDevice, ) -> Result { debug!("Getting network info for device: {:?}", request.pubkey); - let _token = self.validate_session(request.token.as_ref()).await?; + let token = self.validate_session(request.token.as_ref()).await?; Device::validate_pubkey(&request.pubkey).map_err(|_| { error!("Invalid pubkey {}", &request.pubkey); @@ -811,12 +870,148 @@ impl EnrollmentServer { // Find existing device by public key. let Ok(Some(device)) = Device::find_by_pubkey(&self.pool, &request.pubkey).await else { error!("Failed to fetch device by pubkey: {}", &request.pubkey); - return Err(Status::internal("device not found")); + return Err(Status::not_found("device not found")); }; + // check if device owner matches used enrollment token + if device.user_id != token.user_id { + error!( + "Enrollment token does not match device with pubkey {}", + request.pubkey + ); + return Err(Status::unauthenticated( + "enrollment token is not valid for specified device", + )); + } + let token = new_polling_token(&self.pool, &device).await?; build_device_config_response(&self.pool, device, Some(token)).await } + + // TODO: Add events + #[instrument(skip_all)] + pub(crate) async fn register_code_mfa_start( + &self, + request: CodeMfaSetupStartRequest, + ) -> Result { + debug!("Begin enrollment code mfa setup start"); + let method = request.method(); + if method != MfaMethod::Email && method != MfaMethod::Totp { + return Err(Status::invalid_argument("Method not supported".to_string())); + } + let enrollment = Token::find_by_id(&self.pool, &request.token).await?; + let mut user = enrollment.fetch_user(&self.pool).await?; + // available only for unenrolled users + if user.is_enrolled() { + return Err(Status::permission_denied("User is already enrolled")); + } + match method { + MfaMethod::Email => { + let settings = Settings::get_current_settings(); + if !settings.smtp_configured() { + error!("Unable to start Email mfa setup. SMTP is not configured"); + return Err(Status::internal("SMTP not configured".to_string())); + } + if user.email_mfa_enabled { + return Err(Status::invalid_argument( + "Method already enabled".to_string(), + )); + } + user.new_email_secret(&self.pool).await.map_err(|_| { + error!("Failed to create email secret"); + Status::internal("Failed to setup email mfa".to_string()) + })?; + info!("Created email secret for {}", &user.username); + send_email_mfa_activation_email(&user, &self.mail_tx, None).map_err(|e| { + error!("Failed to send email mfa activation email.\nReason:{e}"); + Status::internal("Failed to send activation email".to_string()) + })?; + Ok(CodeMfaSetupStartResponse { totp_secret: None }) + } + MfaMethod::Totp => { + if user.totp_enabled { + return Err(Status::invalid_argument( + "Method already enabled".to_string(), + )); + } + let secret = user.new_totp_secret(&self.pool).await.map_err(|_| { + error!("Failed to make new totp secret"); + Status::internal(String::new()) + })?; + info!("New totp secret created for {}", &user.username); + Ok(CodeMfaSetupStartResponse { + totp_secret: Some(secret), + }) + } + _ => Err(Status::invalid_argument("Method not supported".to_string())), + } + } + + // TODO: Add events + #[instrument(skip_all)] + pub(crate) async fn register_code_mfa_finish( + &self, + request: CodeMfaSetupFinishRequest, + ) -> Result { + debug!("Begin enrollment code mfa setup finish"); + let enrollment = self.validate_session(Some(&request.token)).await?; + let method = request.method(); + if method != MfaMethod::Totp && method != MfaMethod::Email { + return Err(Status::invalid_argument("Method not supported")); + } + let mut user = enrollment.fetch_user(&self.pool).await?; + if user.mfa_enabled { + return Err(Status::invalid_argument( + "Mfa already enabled on the account".to_string(), + )); + } + // available only for unenrolled users + if user.is_enrolled() { + return Err(Status::permission_denied("User is already enrolled")); + } + let mfa_method: MFAMethod; + // enable corresponding MFA + match method { + MfaMethod::Email => { + if !user.verify_email_mfa_code(&request.code) { + return Err(Status::invalid_argument("Email code invalid".to_string())); + } + user.enable_email_mfa(&self.pool) + .await + .map_err(|_| Status::internal("Enabling method failed.".to_string()))?; + mfa_method = MFAMethod::Email; + } + MfaMethod::Totp => { + if !user.verify_totp_code(&request.code) { + return Err(Status::invalid_argument("Code invalid".to_string())); + } + user.enable_totp(&self.pool) + .await + .map_err(|_| Status::internal("Enabling method failed.".to_string()))?; + mfa_method = MFAMethod::OneTimePassword; + } + _ => { + return Err(Status::invalid_argument("Method not supported")); + } + } + user.enable_mfa(&self.pool) + .await + .map_err(|_| Status::internal("Enabling MFA on the account failed.".to_string()))?; + let recovery_codes = user + .get_recovery_codes(&self.pool) + .await + .map_err(|_| Status::internal("Failed to get recovery codes.".to_string()))? + .ok_or_else(|| Status::internal("Recovery codes not found".to_string()))?; + if let Err(e) = send_mfa_configured_email(None, &user, &mfa_method, &self.mail_tx) { + error!("Failed to send mfa configured email\nReason: {e}"); + } + info!( + "Successfully enabled MFA method {} for user {}", + method.as_str_name(), + &user.username + ); + Ok(CodeMfaSetupFinishResponse { recovery_codes }) + } } impl From> for AdminInfo { diff --git a/crates/defguard_core/src/grpc/gateway/client_state.rs b/crates/defguard_core/src/grpc/gateway/client_state.rs index 18b09a4e98..76b5439306 100644 --- a/crates/defguard_core/src/grpc/gateway/client_state.rs +++ b/crates/defguard_core/src/grpc/gateway/client_state.rs @@ -43,6 +43,7 @@ pub struct ClientState { } impl ClientState { + #[must_use] pub fn new( device: Device, user: &User, @@ -84,10 +85,11 @@ impl ClientState { /// Helper struct used to handle connected VPN clients state /// Clients are grouped by location ID type ClientPubKey = String; -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Default, Serialize, Clone)] pub struct ClientMap(HashMap>); impl ClientMap { + #[must_use] pub fn new() -> Self { Self(HashMap::new()) } @@ -119,13 +121,12 @@ impl ClientMap { ); // initialize location map if it doesn't exist yet - let location_map = match self.0.get_mut(&location_id) { - Some(location_map) => location_map, - None => { - // initialize new map for location and immediately return a mutable reference - self.0.insert(location_id, HashMap::new()); - self.0.get_mut(&location_id).unwrap() - } + let location_map = if let Some(location_map) = self.0.get_mut(&location_id) { + location_map + } else { + // initialize new map for location and immediately return a mutable reference + self.0.insert(location_id, HashMap::new()); + self.0.get_mut(&location_id).unwrap() }; // check if client is already connected @@ -134,7 +135,7 @@ impl ClientMap { public_key: public_key.to_string(), location_id, }); - }; + } // add client state to location map let client_state = ClientState::new( @@ -189,11 +190,16 @@ impl ClientMap { .push((client_state.device.clone(), disconnect_event_context)); return false; - }; + } true }); - }; + } Ok(disconnected_clients) } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } } diff --git a/crates/defguard_core/src/grpc/gateway/map.rs b/crates/defguard_core/src/grpc/gateway/map.rs new file mode 100644 index 0000000000..cef0016cdd --- /dev/null +++ b/crates/defguard_core/src/grpc/gateway/map.rs @@ -0,0 +1,221 @@ +use std::collections::HashMap; + +use chrono::Utc; +use defguard_version::tracing::VersionInfo; +use semver::Version; +use sqlx::PgPool; +use thiserror::Error; +use tokio::sync::mpsc::UnboundedSender; +use uuid::Uuid; + +use super::state::GatewayState; +use crate::{db::Id, mail::Mail}; + +/// Helper struct used to handle gateway state. Gateways are grouped by network. +type GatewayHostname = String; +#[derive(Debug, Serialize)] +pub struct GatewayMap(HashMap>); + +#[derive(Debug, Error)] +pub enum GatewayMapError { + #[error("Gateway {1} for network {0} not found")] + NotFound(i64, GatewayHostname), + #[error("Network {0} not found")] + NetworkNotFound(i64), + #[error("Gateway with UID {0} not found")] + UidNotFound(Uuid), + #[error("Cannot remove. Gateway with UID {0} is still active")] + RemoveActive(Uuid), + #[error("Config missing")] + ConfigError, + #[error("Failed to get current settings")] + SettingsError, +} + +impl GatewayMap { + #[must_use] + pub fn new() -> Self { + Self(HashMap::new()) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Add a new gateway to the map. + /// This is meant to be called when Gateway requests a config as a sort of "registration". + pub(crate) fn add_gateway( + &mut self, + network_id: Id, + network_name: &str, + hostname: String, + name: Option, + mail_tx: UnboundedSender, + version: Version, + ) { + info!("Adding gateway {hostname} with to gateway map for network {network_id}",); + let gateway_state = + GatewayState::new(network_id, network_name, &hostname, name, mail_tx, version); + + if let Some(network_gateway_map) = self.0.get_mut(&network_id) { + network_gateway_map.entry(hostname).or_insert(gateway_state); + } else { + // no map for a given network exists yet + let mut network_gateway_map = HashMap::new(); + network_gateway_map.insert(hostname, gateway_state); + self.0.insert(network_id, network_gateway_map); + } + } + + /// Remove gateway from the map. + pub(crate) fn remove_gateway( + &mut self, + network_id: Id, + uid: Uuid, + ) -> Result<(), GatewayMapError> { + debug!("Removing gateway from network {network_id}"); + if let Some(network_gateway_map) = self.0.get_mut(&network_id) { + // find gateway by uuid + let hostname = match network_gateway_map + .iter() + .find(|(_address, state)| state.uid == uid) + { + None => { + error!("Failed to find gateway with UID {uid}"); + return Err(GatewayMapError::UidNotFound(uid)); + } + Some((hostname, state)) => { + if state.connected { + error!("Cannot remove. Gateway with UID {uid} is still active"); + return Err(GatewayMapError::RemoveActive(uid)); + } + hostname.clone() + } + }; + // remove matching gateway + network_gateway_map.remove(&hostname) + } else { + // no map for a given network exists yet + error!("Network {network_id} not found in gateway map"); + return Err(GatewayMapError::NetworkNotFound(network_id)); + }; + info!("Gateway with UID {uid} removed from network {network_id}"); + Ok(()) + } + + /// Change gateway status to connected. + /// Assume that the gateway is already present in the map. + pub(crate) fn connect_gateway( + &mut self, + network_id: Id, + hostname: &str, + pool: &PgPool, + ) -> Result<(), GatewayMapError> { + debug!("Connecting gateway {hostname} in network {network_id}"); + if let Some(network_gateway_map) = self.0.get_mut(&network_id) { + if let Some(state) = network_gateway_map.get_mut(hostname) { + // check if a gateway is reconnecting to avoid sending notifications on initial + // connection + let is_reconnecting = state.disconnected_at.is_some(); + state.connected = true; + state.disconnected_at = None; + state.connected_at = Some(Utc::now().naive_utc()); + state.cancel_pending_disconnect_notification(); + if is_reconnecting { + state.handle_reconnect_notification(pool); + } + debug!( + "Gateway {hostname} found in gateway map, current state: {:?}", + state + ); + } else { + error!("Gateway {hostname} not found in gateway map for network {network_id}"); + return Err(GatewayMapError::NotFound(network_id, hostname.into())); + } + } else { + // no map for a given network exists yet + error!("Network {network_id} not found in gateway map"); + return Err(GatewayMapError::NetworkNotFound(network_id)); + } + info!("Gateway {hostname} connected in network {network_id}"); + Ok(()) + } + + /// Change gateway status to disconnected. + pub(crate) fn disconnect_gateway( + &mut self, + network_id: Id, + hostname: String, + pool: &PgPool, + ) -> Result<(), GatewayMapError> { + debug!("Disconnecting gateway {hostname} in network {network_id}"); + if let Some(network_gateway_map) = self.0.get_mut(&network_id) { + if let Some(state) = network_gateway_map.get_mut(&hostname) { + state.connected = false; + state.disconnected_at = Some(Utc::now().naive_utc()); + state.handle_disconnect_notification(pool); + debug!("Gateway {hostname} found in gateway map, current state: {state:?}"); + info!("Gateway {hostname} disconnected in network {network_id}"); + return Ok(()); + } + } + let err = GatewayMapError::NotFound(network_id, hostname); + error!("Gateway disconnect failed: {err}"); + Err(err) + } + + /// Return `true` if at least one gateway in a given network is connected. + #[must_use] + pub(crate) fn connected(&self, network_id: Id) -> bool { + match self.0.get(&network_id) { + Some(network_gateway_map) => network_gateway_map + .values() + .any(|gateway| gateway.connected), + None => false, + } + } + + /// Return a list of all statuses of all gateways for a given network. + #[must_use] + pub fn get_network_gateway_status(&self, network_id: Id) -> Vec { + if let Some(network_gateway_map) = self.0.get(&network_id) { + network_gateway_map.values().cloned().collect() + } else { + Vec::new() + } + } + + /// Flattens the inner `HashMap` into `Vec`. + /// + /// Since key information in inner HashMap is within `GatewayState` it's simpler to consume it + /// as `Vec` on web. + /// + /// # Returns + /// `HashMap>` from `GatewayMap` + #[must_use] + pub(crate) fn as_flattened(&self) -> HashMap> { + self.0 + .iter() + .map(|(id, inner_map)| { + let states: Vec = inner_map.values().cloned().collect(); + (*id, states) + }) + .collect() + } + + #[allow(dead_code)] + #[must_use] + pub(crate) fn all_states_as_version_info(&self) -> Vec { + self.0 + .values() + .flat_map(|inner_map| inner_map.values().map(GatewayState::as_version_info)) + .collect() + } +} + +impl Default for GatewayMap { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/defguard_core/src/grpc/gateway/mod.rs b/crates/defguard_core/src/grpc/gateway/mod.rs index dd5293e575..39f6fd283d 100644 --- a/crates/defguard_core/src/grpc/gateway/mod.rs +++ b/crates/defguard_core/src/grpc/gateway/mod.rs @@ -1,4 +1,3 @@ -mod client_state; use std::{ net::{IpAddr, SocketAddr}, pin::Pin, @@ -8,6 +7,8 @@ use std::{ use chrono::{DateTime, TimeDelta, Utc}; use client_state::ClientMap; +use defguard_version::version_info_from_metadata; +use semver::Version; use sqlx::{Error as SqlxError, PgExecutor, PgPool, query}; use thiserror::Error; use tokio::{ @@ -21,7 +22,8 @@ use tokio::{ use tokio_stream::Stream; use tonic::{Code, Request, Response, Status, metadata::MetadataMap}; -use super::{GatewayMap, proto::enterprise::firewall::FirewallConfig}; +use self::map::GatewayMap; +use super::proto::enterprise::firewall::FirewallConfig; pub use crate::grpc::proto::gateway::{ Configuration, ConfigurationRequest, Peer, PeerStats, StatsUpdate, Update, gateway_service_server, stats_update, update, @@ -35,6 +37,10 @@ use crate::{ mail::Mail, }; +pub mod client_state; +pub mod map; +pub(crate) mod state; + const PEER_DISCONNECT_INTERVAL: u64 = 60; /// Sends given `GatewayEvent` to be handled by gateway GRPC server @@ -118,7 +124,14 @@ impl WireguardNetwork { .map(|row| Peer { pubkey: row.pubkey, allowed_ips: row.allowed_ips, - preshared_key: row.preshared_key, + // Don't send preshared key if MFA is not enabled, it can't be used and may + // cause issues with clients connecting if they expect no preshared key + // e.g. when you disable MFA on a location + preshared_key: if self.mfa_enabled() { + row.preshared_key + } else { + None + }, keepalive_interval: Some(self.keepalive_interval as u32), }) .collect(); @@ -127,20 +140,29 @@ impl WireguardNetwork { } } +/// Utility struct encapsulating commonly extracted metadata fields during gRPC communication. +struct GatewayMetadata { + network_id: Id, + hostname: String, + version: Version, + // info: String, +} + impl GatewayServer { /// Create new gateway server instance #[must_use] pub fn new( pool: PgPool, - state: Arc>, + gateway_state: Arc>, + client_state: Arc>, wireguard_tx: Sender, mail_tx: UnboundedSender, grpc_event_tx: UnboundedSender, ) -> Self { Self { pool, - gateway_state: state, - client_state: Arc::new(Mutex::new(ClientMap::new())), + gateway_state, + client_state, wireguard_tx, mail_tx, grpc_event_tx, @@ -158,10 +180,10 @@ impl GatewayServer { } // parse network id from gateway request metadata from intercepted information from JWT token - fn get_network_id_from_metadata(metadata: &MetadataMap) -> Option { + fn get_network_id_from_metadata(metadata: &MetadataMap) -> Option { if let Some(ascii_value) = metadata.get("gateway_network_id") { if let Ok(slice) = ascii_value.clone().to_str() { - if let Ok(id) = slice.parse::() { + if let Ok(id) = slice.parse::() { return Some(id); } } @@ -276,6 +298,16 @@ impl GatewayServer { Ok(user) } + + /// Utility function extracting metadata fields during gRPC communication. + fn extract_metadata(metadata: &MetadataMap) -> Result { + let (version, _info) = version_info_from_metadata(metadata); + Ok(GatewayMetadata { + network_id: Self::get_network_id(metadata)?, + hostname: Self::get_gateway_hostname(metadata)?, + version, + }) + } } fn gen_config( @@ -721,13 +753,20 @@ impl gateway_service_server::GatewayService for GatewayServer { &self, request: Request>, ) -> Result, Status> { - let network_id = Self::get_network_id(request.metadata())?; - let gateway_hostname = Self::get_gateway_hostname(request.metadata())?; + let GatewayMetadata { + network_id, + hostname, + .. + } = Self::extract_metadata(request.metadata())?; let mut stream = request.into_inner(); let mut disconnect_timer = interval(Duration::from_secs(PEER_DISCONNECT_INTERVAL)); - + // FIXME: tracing causes looping messages, like `INFO gateway_config:gateway_stats:...`. + // let span = tracing::info_span!("gateway_stats", component = %DefguardComponent::Gateway, + // version = version.to_string(), info); + // let _guard = span.enter(); loop { - // wait for a message or update client map at least once a mninute if no messages are received + // Wait for a message or update client map at least once a mninute, if no messages are + // received. let stats_update = tokio::select! { message = stream.message() => { match message? { @@ -736,7 +775,8 @@ impl gateway_service_server::GatewayService for GatewayServer { } } _ = disconnect_timer.tick() => { - debug!("No stats updates received in last {PEER_DISCONNECT_INTERVAL} seconds. Updating disconnected VPN clients"); + debug!("No stats updates received in last {PEER_DISCONNECT_INTERVAL} seconds. \ + Updating disconnected VPN clients"); // fetch location to get current peer disconnect threshold let location = self.fetch_location_from_db(network_id).await?; @@ -821,7 +861,7 @@ impl gateway_service_server::GatewayService for GatewayServer { // mark new VPN client as connected client_map.connect_vpn_client( network_id, - &gateway_hostname, + &hostname, &public_key, &device, &user, @@ -845,7 +885,7 @@ impl gateway_service_server::GatewayService for GatewayServer { })?; } } - }; + } // disconnect inactive clients client_map.disconnect_inactive_vpn_clients_for_location(&location)? @@ -884,8 +924,17 @@ impl gateway_service_server::GatewayService for GatewayServer { request: Request, ) -> Result, Status> { debug!("Sending configuration to gateway client."); - let network_id = Self::get_network_id(request.metadata())?; - let hostname = Self::get_gateway_hostname(request.metadata())?; + let GatewayMetadata { + network_id, + hostname, + version, + .. + // info, + } = Self::extract_metadata(request.metadata())?; + // FIXME: tracing causes looping messages, like `INFO gateway_config:gateway_stats:...`. + // let span = tracing::info_span!("gateway_config", component = %DefguardComponent::Gateway, + // version = version.to_string(), info); + // let _guard = span.enter(); let mut conn = self.pool.acquire().await.map_err(|e| { error!("Failed to acquire DB connection: {e}"); @@ -919,6 +968,7 @@ impl gateway_service_server::GatewayService for GatewayServer { hostname, request.into_inner().name, self.mail_tx.clone(), + version, ); } @@ -956,22 +1006,30 @@ impl gateway_service_server::GatewayService for GatewayServer { } async fn updates(&self, request: Request<()>) -> Result, Status> { - let gateway_network_id = Self::get_network_id(request.metadata())?; - let hostname = Self::get_gateway_hostname(request.metadata())?; - - let Some(network) = WireguardNetwork::find_by_id(&self.pool, gateway_network_id) + let GatewayMetadata { + network_id, + hostname, + .. + // info, + } = Self::extract_metadata(request.metadata())?; + // FIXME: tracing causes looping messages, like `INFO gateway_config:gateway_stats:...`. + // let span = tracing::info_span!("gateway_updates", component = %DefguardComponent::Gateway, + // version = version.to_string(), info); + // let _guard = span.enter(); + + let Some(network) = WireguardNetwork::find_by_id(&self.pool, network_id) .await .map_err(|_| { - error!("Failed to fetch network {gateway_network_id} from the database"); + error!("Failed to fetch network {network_id} from the database"); Status::new( Code::Internal, - format!("Failed to retrieve network {gateway_network_id} from the database"), + format!("Failed to retrieve network {network_id} from the database"), ) })? else { return Err(Status::new( Code::Internal, - format!("Network with id {gateway_network_id} not found"), + format!("Network with id {network_id} not found"), )); }; @@ -981,32 +1039,27 @@ impl gateway_service_server::GatewayService for GatewayServer { let events_rx = self.wireguard_tx.subscribe(); let mut state = self.gateway_state.lock().unwrap(); state - .connect_gateway(gateway_network_id, &hostname, &self.pool) + .connect_gateway(network_id, &hostname, &self.pool) .map_err(|err| { - error!("Failed to connect gateway on network {gateway_network_id}: {err}"); + error!("Failed to connect gateway on network {network_id}: {err}"); Status::new( Code::Internal, - "Failed to connect gateway on network {gateway_network_id}", + format!("Failed to connect gateway on network {network_id}"), ) })?; // clone here before moving into a closure let gateway_hostname = hostname.clone(); let handle = tokio::spawn(async move { - let mut update_handler = GatewayUpdatesHandler::new( - gateway_network_id, - network, - gateway_hostname, - events_rx, - tx, - ); + let mut update_handler = + GatewayUpdatesHandler::new(network_id, network, gateway_hostname, events_rx, tx); update_handler.run().await; }); Ok(Response::new(GatewayUpdatesStream::new( handle, rx, - gateway_network_id, + network_id, hostname, Arc::clone(&self.gateway_state), self.pool.clone(), diff --git a/crates/defguard_core/src/grpc/gateway/state.rs b/crates/defguard_core/src/grpc/gateway/state.rs new file mode 100644 index 0000000000..b44fd21c13 --- /dev/null +++ b/crates/defguard_core/src/grpc/gateway/state.rs @@ -0,0 +1,178 @@ +use std::time::Duration; + +use chrono::NaiveDateTime; +use defguard_version::{DefguardComponent, tracing::VersionInfo}; +use semver::Version; +use serde::Serialize; +use sqlx::PgPool; +use tokio::{sync::mpsc::UnboundedSender, time::sleep}; +use tokio_util::sync::CancellationToken; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + db::{Id, Settings}, + grpc::MIN_GATEWAY_VERSION, + handlers::mail::{send_gateway_disconnected_email, send_gateway_reconnected_email}, + mail::Mail, +}; + +#[derive(Clone, Debug, Serialize, ToSchema)] +pub struct GatewayState { + pub uid: Uuid, + pub connected: bool, + pub network_id: Id, + pub network_name: String, + pub name: Option, + pub hostname: String, + pub connected_at: Option, + pub disconnected_at: Option, + #[serde(skip)] + pub mail_tx: UnboundedSender, + #[serde(skip)] + pub pending_notification_cancel_token: Option, + #[schema(value_type = String)] + pub version: Version, +} + +impl GatewayState { + #[must_use] + pub fn new>( + network_id: Id, + network_name: S, + hostname: S, + name: Option, + mail_tx: UnboundedSender, + version: Version, + ) -> Self { + Self { + uid: Uuid::new_v4(), + connected: false, + network_id, + network_name: network_name.into(), + name, + hostname: hostname.into(), + connected_at: None, + disconnected_at: None, + mail_tx, + pending_notification_cancel_token: None, + version, + } + } + + /// Checks if gateway disconnect notification should be sent. + pub(super) fn handle_disconnect_notification(&mut self, pool: &PgPool) { + debug!("Checking if gateway disconnect notification needs to be sent"); + let settings = Settings::get_current_settings(); + if settings.gateway_disconnect_notifications_enabled { + let delay = Duration::from_secs( + 60 * settings.gateway_disconnect_notifications_inactivity_threshold as u64, + ); + self.send_disconnect_notification(pool, delay); + } + } + + /// Send gateway disconnected notification + /// Sends notification only if last notification time is bigger than specified in config + fn send_disconnect_notification(&mut self, pool: &PgPool, delay: Duration) { + // Clone here because self doesn't live long enough + let name = self.name.clone(); + let mail_tx = self.mail_tx.clone(); + let pool = pool.clone(); + let hostname = self.hostname.clone(); + let network_name = self.network_name.clone(); + + debug!( + "Scheduling gateway disconnect email notification for {hostname} to be sent in \ + {delay:?}" + ); + // use cancellation token to abort sending if gateway reconnects during the delay + // we should never need to cancel a previous token since that would've been done on reconnect + assert!(self.pending_notification_cancel_token.is_none()); + let cancellation_token = CancellationToken::new(); + self.pending_notification_cancel_token = Some(cancellation_token.clone()); + + // notification is not supposed to be sent immediately, so we instead schedule a + // background task with a configured delay + tokio::spawn(async move { + tokio::select! { + () = async { + sleep(delay).await; + debug!("Gateway disconnect notification delay has passed. \ + Trying to send email..."); + if let Err(e) = send_gateway_disconnected_email(name, network_name, &hostname, + &mail_tx, &pool) + .await + { + error!("Failed to send gateway disconnect notification: {e}"); + } else { + info!("Gateway {hostname} disconnected. Email notification sent",); + } + } => { + debug!("Scheduled gateway disconnect notification for {hostname} has been \ + sent"); + }, + () = cancellation_token.cancelled() => { + info!("Scheduled gateway disconnect notification for {hostname} cancelled"); + } + } + }); + } + + /// Checks if gateway disconnect notification should be sent. + pub(super) fn handle_reconnect_notification(&mut self, pool: &PgPool) { + debug!("Checking if gateway reconnect notification needs to be sent"); + let settings = Settings::get_current_settings(); + if settings.gateway_disconnect_notifications_reconnect_notification_enabled { + self.send_reconnect_notification(pool); + } + } + + /// Send gateway disconnected notification + /// Sends notification only if last notification time is bigger than specified in config + fn send_reconnect_notification(&mut self, pool: &PgPool) { + debug!("Sending gateway reconnect email notification"); + // Clone here because self doesn't live long enough + let name = self.name.clone(); + let mail_tx = self.mail_tx.clone(); + let pool = pool.clone(); + let hostname = self.hostname.clone(); + let network_name = self.network_name.clone(); + tokio::spawn(async move { + if let Err(e) = + send_gateway_reconnected_email(name, network_name, &hostname, &mail_tx, &pool).await + { + error!("Failed to send gateway reconnect notification: {e}"); + } else { + info!("Gateway {hostname} reconnected. Email notification sent",); + } + }); + } + + /// Cancels disconnect notification if one is scheduled to be sent + pub(super) fn cancel_pending_disconnect_notification(&mut self) { + debug!( + "Checking if there's a gateway disconnect notification for {} pending which needs \ + to be cancelled", + self.hostname + ); + if let Some(token) = &self.pending_notification_cancel_token { + debug!( + "Cancelling pending gateway disconnect notification for {}", + self.hostname + ); + token.cancel(); + self.pending_notification_cancel_token = None; + } + } + + #[allow(dead_code)] + pub(super) fn as_version_info(&self) -> VersionInfo { + VersionInfo { + component: Some(DefguardComponent::Gateway), + info: None, + version: Some(self.version.to_string()), + is_supported: self.version >= MIN_GATEWAY_VERSION, + } + } +} diff --git a/crates/defguard_core/src/grpc/interceptor.rs b/crates/defguard_core/src/grpc/interceptor.rs index 85e93f231f..51e8a865d9 100644 --- a/crates/defguard_core/src/grpc/interceptor.rs +++ b/crates/defguard_core/src/grpc/interceptor.rs @@ -1,6 +1,9 @@ use tonic::{Status, service::Interceptor}; -use crate::auth::{Claims, ClaimsType}; +use crate::{ + auth::{Claims, ClaimsType}, + grpc::{AUTHORIZATION_HEADER, HOSTNAME_HEADER}, +}; /// Auth interceptor used by gRPC services. Verifies JWT token sent /// in gRPC metadata under "authorization" key. @@ -21,10 +24,10 @@ impl Interceptor for JwtInterceptor { // This is only used for logging purposes, so no proper error handling let hostname = req .metadata() - .get("hostname") + .get(HOSTNAME_HEADER) .map_or("UNKNOWN", |h| h.to_str().unwrap_or("UNKNOWN")); - let token = match req.metadata().get("authorization") { + let token = match req.metadata().get(AUTHORIZATION_HEADER) { Some(token) => token.to_str().map_err(|err| { warn!( "Failed to parse authorization header during handling gRPC request from \ diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index 89e7e0b16c..c414f078f3 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -6,16 +6,21 @@ use std::{ #[cfg(any(feature = "wireguard", feature = "worker"))] use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, RwLock}, }; -use chrono::{NaiveDateTime, Utc}; +use axum::http::Uri; +#[cfg(feature = "wireguard")] +use defguard_version::server::DefguardVersionLayer; +use defguard_version::{ + ComponentInfo, DefguardComponent, Version, client::ClientVersionInterceptor, + get_tracing_variables, +}; use openidconnect::{AuthorizationCode, Nonce, Scope, core::CoreAuthenticationFlow}; use reqwest::Url; use serde::Serialize; #[cfg(feature = "worker")] use sqlx::PgPool; -use thiserror::Error; use tokio::{ sync::{ broadcast::Sender, @@ -24,19 +29,19 @@ use tokio::{ time::sleep, }; use tokio_stream::wrappers::UnboundedReceiverStream; -use tokio_util::sync::CancellationToken; use tonic::{ - Code, Status, - transport::{Certificate, ClientTlsConfig, Endpoint, Identity, Server, ServerTlsConfig}, + Code, Status, Streaming, + transport::{ + Certificate, ClientTlsConfig, Endpoint, Identity, Server, ServerTlsConfig, server::Router, + }, }; -use utoipa::ToSchema; -use uuid::Uuid; +use tower::ServiceBuilder; #[cfg(feature = "wireguard")] use self::gateway::{GatewayServer, gateway_service_server::GatewayServiceServer}; use self::{ auth::{AuthServer, auth_service_server::AuthServiceServer}, - desktop_client_mfa::ClientMfaServer, + client_mfa::ClientMfaServer, enrollment::EnrollmentServer, password_reset::PasswordResetServer, proto::proxy::core_response, @@ -46,9 +51,10 @@ use self::{ interceptor::JwtInterceptor, proto::worker::worker_service_server::WorkerServiceServer, worker::WorkerServer, }; -#[cfg(feature = "worker")] -use crate::{auth::ClaimsType, db::GatewayEvent}; +#[cfg(feature = "wireguard")] +pub use crate::version::MIN_GATEWAY_VERSION; use crate::{ + VERSION, auth::failed_login::FailedLoginMap, db::{ AppEvent, Id, Settings, @@ -58,21 +64,28 @@ use crate::{ db::models::{enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider}, directory_sync::sync_user_groups_if_configured, grpc::polling::PollingServer, - handlers::openid_login::{build_state, make_oidc_client, user_from_claims}, + handlers::openid_login::{ + SELECT_ACCOUNT_SUPPORTED_PROVIDERS, build_state, make_oidc_client, user_from_claims, + }, is_enterprise_enabled, ldap::utils::ldap_update_user_state, }, events::{BidiStreamEvent, GrpcEvent}, - handlers::mail::{send_gateway_disconnected_email, send_gateway_reconnected_email}, + grpc::gateway::{client_state::ClientMap, map::GatewayMap}, mail::Mail, server_config, + version::{IncompatibleComponents, IncompatibleProxyData, is_proxy_version_supported}, }; +#[cfg(feature = "worker")] +use crate::{auth::ClaimsType, db::GatewayEvent}; + +static VERSION_ZERO: Version = Version::new(0, 0, 0); mod auth; -pub(crate) mod desktop_client_mfa; +pub(crate) mod client_mfa; pub mod enrollment; #[cfg(feature = "wireguard")] -pub(crate) mod gateway; +pub mod gateway; #[cfg(any(feature = "wireguard", feature = "worker"))] mod interceptor; pub mod password_reset; @@ -104,358 +117,15 @@ pub mod proto { } use proto::proxy::{ - AuthCallbackResponse, AuthInfoResponse, CoreError, CoreResponse, core_request, + AuthCallbackResponse, AuthInfoResponse, CoreError, CoreRequest, CoreResponse, core_request, proxy_client::ProxyClient, }; -// Helper struct used to handle gateway state -// gateways are grouped by network -type GatewayHostname = String; -#[derive(Debug, Serialize, Clone)] -pub struct GatewayMap(HashMap>); - -#[derive(Error, Debug)] -pub enum GatewayMapError { - #[error("Gateway {1} for network {0} not found")] - NotFound(i64, GatewayHostname), - #[error("Network {0} not found")] - NetworkNotFound(i64), - #[error("Gateway with UID {0} not found")] - UidNotFound(Uuid), - #[error("Cannot remove. Gateway with UID {0} is still active")] - RemoveActive(Uuid), - #[error("Config missing")] - ConfigError, - #[error("Failed to get current settings")] - SettingsError, -} - -impl GatewayMap { - #[must_use] - pub fn new() -> Self { - Self(HashMap::new()) - } - - // add a new gateway to map - // this method is meant to be called when a gateway requests a config - // as a sort of "registration" - pub fn add_gateway( - &mut self, - network_id: Id, - network_name: &str, - hostname: String, - name: Option, - mail_tx: UnboundedSender, - ) { - info!("Adding gateway {hostname} with to gateway map for network {network_id}",); - let gateway_state = GatewayState::new(network_id, network_name, &hostname, name, mail_tx); - - if let Some(network_gateway_map) = self.0.get_mut(&network_id) { - network_gateway_map.entry(hostname).or_insert(gateway_state); - } else { - // no map for a given network exists yet - let mut network_gateway_map = HashMap::new(); - network_gateway_map.insert(hostname, gateway_state); - self.0.insert(network_id, network_gateway_map); - } - } - - // remove gateway from map - pub fn remove_gateway(&mut self, network_id: Id, uid: Uuid) -> Result<(), GatewayMapError> { - debug!("Removing gateway from network {network_id}"); - if let Some(network_gateway_map) = self.0.get_mut(&network_id) { - // find gateway by uuid - let hostname = match network_gateway_map - .iter() - .find(|(_address, state)| state.uid == uid) - { - None => { - error!("Failed to find gateway with UID {uid}"); - return Err(GatewayMapError::UidNotFound(uid)); - } - Some((hostname, state)) => { - if state.connected { - error!("Cannot remove. Gateway with UID {uid} is still active"); - return Err(GatewayMapError::RemoveActive(uid)); - } - hostname.clone() - } - }; - // remove matching gateway - network_gateway_map.remove(&hostname) - } else { - // no map for a given network exists yet - error!("Network {network_id} not found in gateway map"); - return Err(GatewayMapError::NetworkNotFound(network_id)); - }; - info!("Gateway with UID {uid} removed from network {network_id}"); - Ok(()) - } - - // change gateway status to connected - // we assume that the gateway is already present in hashmap - pub fn connect_gateway( - &mut self, - network_id: Id, - hostname: &str, - pool: &PgPool, - ) -> Result<(), GatewayMapError> { - debug!("Connecting gateway {hostname} in network {network_id}"); - if let Some(network_gateway_map) = self.0.get_mut(&network_id) { - if let Some(state) = network_gateway_map.get_mut(hostname) { - // check if a gateway is reconnecting to avoid sending notifications on initial - // connection - let is_reconnecting = state.disconnected_at.is_some(); - state.connected = true; - state.disconnected_at = None; - state.connected_at = Some(Utc::now().naive_utc()); - state.cancel_pending_disconnect_notification(); - if is_reconnecting { - state.handle_reconnect_notification(pool); - } - debug!( - "Gateway {hostname} found in gateway map, current state: {:?}", - state - ); - } else { - error!("Gateway {hostname} not found in gateway map for network {network_id}"); - return Err(GatewayMapError::NotFound(network_id, hostname.into())); - } - } else { - // no map for a given network exists yet - error!("Network {network_id} not found in gateway map"); - return Err(GatewayMapError::NetworkNotFound(network_id)); - } - info!("Gateway {hostname} connected in network {network_id}"); - Ok(()) - } - - // change gateway status to disconnected - pub fn disconnect_gateway( - &mut self, - network_id: Id, - hostname: String, - pool: &PgPool, - ) -> Result<(), GatewayMapError> { - debug!("Disconnecting gateway {hostname} in network {network_id}"); - if let Some(network_gateway_map) = self.0.get_mut(&network_id) { - if let Some(state) = network_gateway_map.get_mut(&hostname) { - state.connected = false; - state.disconnected_at = Some(Utc::now().naive_utc()); - state.handle_disconnect_notification(pool); - debug!("Gateway {hostname} found in gateway map, current state: {state:?}"); - info!("Gateway {hostname} disconnected in network {network_id}"); - return Ok(()); - } - } - let err = GatewayMapError::NotFound(network_id, hostname); - error!("Gateway disconnect failed: {err}"); - Err(err) - } - - // return `true` if at least one gateway in a given network is connected - #[must_use] - pub fn connected(&self, network_id: Id) -> bool { - match self.0.get(&network_id) { - Some(network_gateway_map) => network_gateway_map - .values() - .any(|gateway| gateway.connected), - None => false, - } - } - - // return a list af aff statuses af all gateways in a given network - #[must_use] - pub fn get_network_gateway_status(&self, network_id: Id) -> Vec { - match self.0.get(&network_id) { - Some(network_gateway_map) => network_gateway_map.clone().into_values().collect(), - None => Vec::new(), - } - } - - // return gateway name - #[must_use] - pub fn get_network_gateway_name(&self, network_id: Id, hostname: &str) -> Option { - match self.0.get(&network_id) { - Some(network_gateway_map) => { - if let Some(state) = network_gateway_map.get(hostname) { - state.name.clone() - } else { - None - } - } - None => None, - } - } - - /// Flattens the inner hashmap into an `Vec` - /// - /// Since key information in inner hashmap is within `GatewayState` it's simpler to consume it as Vec on web. - /// - /// # Returns - /// Returns `HashMap>` from `GatewayMap` - pub fn into_flattened(self) -> HashMap> { - self.0 - .into_iter() - .map(|(id, inner_map)| { - let states: Vec = inner_map.into_values().collect(); - (id, states) - }) - .collect() - } -} - -impl Default for GatewayMap { - fn default() -> Self { - Self::new() - } -} - -#[derive(Serialize, Clone, Debug, ToSchema)] -pub struct GatewayState { - pub uid: Uuid, - pub connected: bool, - pub network_id: Id, - pub network_name: String, - pub name: Option, - pub hostname: String, - pub connected_at: Option, - pub disconnected_at: Option, - #[serde(skip)] - pub mail_tx: UnboundedSender, - #[serde(skip)] - pub pending_notification_cancel_token: Option, -} - -impl GatewayState { - #[must_use] - pub fn new>( - network_id: Id, - network_name: S, - hostname: S, - name: Option, - mail_tx: UnboundedSender, - ) -> Self { - Self { - uid: Uuid::new_v4(), - connected: false, - network_id, - network_name: network_name.into(), - name, - hostname: hostname.into(), - connected_at: None, - disconnected_at: None, - mail_tx, - pending_notification_cancel_token: None, - } - } +// gRPC header for passing auth token from clients +pub static AUTHORIZATION_HEADER: &str = "authorization"; - /// Checks if gateway disconnect notification should be sent. - fn handle_disconnect_notification(&mut self, pool: &PgPool) { - debug!("Checking if gateway disconnect notification needs to be sent"); - let settings = Settings::get_current_settings(); - if settings.gateway_disconnect_notifications_enabled { - let delay = Duration::from_secs( - 60 * settings.gateway_disconnect_notifications_inactivity_threshold as u64, - ); - self.send_disconnect_notification(pool, delay); - } - } - - /// Send gateway disconnected notification - /// Sends notification only if last notification time is bigger than specified in config - fn send_disconnect_notification(&mut self, pool: &PgPool, delay: Duration) { - // Clone here because self doesn't live long enough - let name = self.name.clone(); - let mail_tx = self.mail_tx.clone(); - let pool = pool.clone(); - let hostname = self.hostname.clone(); - let network_name = self.network_name.clone(); - - debug!( - "Scheduling gateway disconnect email notification for {hostname} to be sent in \ - {delay:?}" - ); - // use cancellation token to abort sending if gateway reconnects during the delay - // we should never need to cancel a previous token since that would've been done on reconnect - assert!(self.pending_notification_cancel_token.is_none()); - let cancellation_token = CancellationToken::new(); - self.pending_notification_cancel_token = Some(cancellation_token.clone()); - - // notification is not supposed to be sent immediately, so we instead schedule a - // background task with a configured delay - tokio::spawn(async move { - tokio::select! { - () = async { - sleep(delay).await; - debug!("Gateway disconnect notification delay has passed. \ - Trying to send email..."); - if let Err(e) = send_gateway_disconnected_email(name, network_name, &hostname, - &mail_tx, &pool) - .await - { - error!("Failed to send gateway disconnect notification: {e}"); - } else { - info!("Gateway {hostname} disconnected. Email notification sent",); - } - } => { - debug!("Scheduled gateway disconnect notification for {hostname} has been \ - sent"); - }, - () = cancellation_token.cancelled() => { - info!("Scheduled gateway disconnect notification for {hostname} cancelled"); - } - } - }); - } - - /// Checks if gateway disconnect notification should be sent. - fn handle_reconnect_notification(&mut self, pool: &PgPool) { - debug!("Checking if gateway reconnect notification needs to be sent"); - let settings = Settings::get_current_settings(); - if settings.gateway_disconnect_notifications_reconnect_notification_enabled { - self.send_reconnect_notification(pool); - } - } - - /// Send gateway disconnected notification - /// Sends notification only if last notification time is bigger than specified in config - fn send_reconnect_notification(&mut self, pool: &PgPool) { - debug!("Sending gateway reconnect email notification"); - // Clone here because self doesn't live long enough - let name = self.name.clone(); - let mail_tx = self.mail_tx.clone(); - let pool = pool.clone(); - let hostname = self.hostname.clone(); - let network_name = self.network_name.clone(); - tokio::spawn(async move { - if let Err(e) = - send_gateway_reconnected_email(name, network_name, &hostname, &mail_tx, &pool).await - { - error!("Failed to send gateway reconnect notification: {e}"); - } else { - info!("Gateway {hostname} reconnected. Email notification sent",); - } - }); - } - - /// Cancels disconnect notification if one is scheduled to be sent - fn cancel_pending_disconnect_notification(&mut self) { - debug!( - "Checking if there's a gateway disconnect notification for {} pending which needs \ - to be cancelled", - self.hostname - ); - if let Some(token) = &self.pending_notification_cancel_token { - debug!( - "Cancelling pending gateway disconnect notification for {}", - self.hostname - ); - token.cancel(); - self.pending_notification_cancel_token = None; - } - } -} +// gRPC header for passing hostname from clients +pub static HOSTNAME_HEADER: &str = "hostname"; const TEN_SECS: Duration = Duration::from_secs(10); @@ -468,373 +138,536 @@ impl From for CoreError { } } -/// Bi-directional gRPC stream for communication with Defguard proxy. -#[instrument(skip_all)] -pub async fn run_grpc_bidi_stream( +struct ProxyMessageLoopContext<'a> { pool: PgPool, + tx: UnboundedSender, wireguard_tx: Sender, - mail_tx: UnboundedSender, - bidi_event_tx: UnboundedSender, -) -> Result<(), anyhow::Error> { - let config = server_config(); - - // TODO: merge the two - let enrollment_server = EnrollmentServer::new( - pool.clone(), - wireguard_tx.clone(), - mail_tx.clone(), - bidi_event_tx.clone(), - ); - let password_reset_server = - PasswordResetServer::new(pool.clone(), mail_tx.clone(), bidi_event_tx.clone()); - let mut client_mfa_server = - ClientMfaServer::new(pool.clone(), mail_tx, wireguard_tx.clone(), bidi_event_tx); - let polling_server = PollingServer::new(pool.clone()); - - let endpoint = Endpoint::from_shared(config.proxy_url.as_deref().unwrap())?; - let endpoint = endpoint - .http2_keep_alive_interval(TEN_SECS) - .tcp_keepalive(Some(TEN_SECS)) - .keep_alive_while_idle(true); - let endpoint = if let Some(ca) = &config.proxy_grpc_ca { - let ca = read_to_string(ca)?; - let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca)); - endpoint.tls_config(tls)? - } else { - endpoint.tls_config(ClientTlsConfig::new().with_enabled_roots())? - }; + resp_stream: &'a mut Streaming, + enrollment_server: &'a mut EnrollmentServer, + password_reset_server: &'a mut PasswordResetServer, + client_mfa_server: &'a mut ClientMfaServer, + polling_server: &'a mut PollingServer, + endpoint_uri: &'a Uri, +} - loop { - debug!("Connecting to proxy at {}", endpoint.uri()); - let mut client = ProxyClient::new(endpoint.connect_lazy()); - let (tx, rx) = mpsc::unbounded_channel(); - let Ok(response) = client.bidi(UnboundedReceiverStream::new(rx)).await else { - error!( - "Failed to connect to proxy @ {}, retrying in 10s", - endpoint.uri() - ); - sleep(TEN_SECS).await; - continue; - }; - info!("Connected to proxy at {}", endpoint.uri()); - let mut resp_stream = response.into_inner(); - 'message: loop { - match resp_stream.message().await { - Ok(None) => { - info!("stream was closed by the sender"); - break 'message; - } - Ok(Some(received)) => { - info!("Received message from proxy."); - debug!("Received the following message from proxy: {received:?}"); - let payload = match received.payload { - // rpc StartEnrollment (EnrollmentStartRequest) returns (EnrollmentStartResponse) - Some(core_request::Payload::EnrollmentStart(request)) => { - match enrollment_server - .start_enrollment(request, received.device_info) - .await - { - Ok(response_payload) => { - Some(core_response::Payload::EnrollmentStart(response_payload)) - } - Err(err) => { - error!("start enrollment error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } +#[instrument(skip_all)] +async fn handle_proxy_message_loop( + context: ProxyMessageLoopContext<'_>, +) -> Result<(), anyhow::Error> { + let pool = context.pool.clone(); + 'message: loop { + match context.resp_stream.message().await { + Ok(None) => { + info!("stream was closed by the sender"); + break 'message; + } + Ok(Some(received)) => { + info!("Received message from proxy."); + debug!("Received the following message from proxy: {received:?}"); + let payload = match received.payload { + // rpc CodeMfaSetupStart return (CodeMfaSetupStartResponse) + Some(core_request::Payload::CodeMfaSetupStart(request)) => { + match context + .enrollment_server + .register_code_mfa_start(request) + .await + { + Ok(response) => { + Some(core_response::Payload::CodeMfaSetupStartResponse(response)) + } + Err(err) => { + error!("Register mfa start error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } - // rpc ActivateUser (ActivateUserRequest) returns (google.protobuf.Empty) - Some(core_request::Payload::ActivateUser(request)) => { - match enrollment_server - .activate_user(request, received.device_info) - .await - { - Ok(()) => Some(core_response::Payload::Empty(())), - Err(err) => { - error!("activate user error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + // rpc CodeMfaSetupFinish return (CodeMfaSetupFinishResponse) + Some(core_request::Payload::CodeMfaSetupFinish(request)) => { + match context + .enrollment_server + .register_code_mfa_finish(request) + .await + { + Ok(response) => { + Some(core_response::Payload::CodeMfaSetupFinishResponse(response)) + } + Err(err) => { + error!("Register mfa finish error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } - // rpc CreateDevice (NewDevice) returns (DeviceConfigResponse) - Some(core_request::Payload::NewDevice(request)) => { - match enrollment_server - .create_device(request, received.device_info) - .await - { - Ok(response_payload) => { - Some(core_response::Payload::DeviceConfig(response_payload)) - } - Err(err) => { - error!("create device error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + // rpc ClientMfaTokenValidation return (ClientMfaTokenValidationResponse) + Some(core_request::Payload::ClientMfaTokenValidation(request)) => { + match context.client_mfa_server.validate_mfa_token(request).await { + Ok(response_payload) => Some( + core_response::Payload::ClientMfaTokenValidation(response_payload), + ), + Err(err) => { + error!("Client MFA validate token error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } - // rpc GetNetworkInfo (ExistingDevice) returns (DeviceConfigResponse) - Some(core_request::Payload::ExistingDevice(request)) => { - match enrollment_server.get_network_info(request).await { - Ok(response_payload) => { - Some(core_response::Payload::DeviceConfig(response_payload)) - } - Err(err) => { - error!("get network info error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + // rpc RegisterMobileAuth (RegisterMobileAuthRequest) return (google.protobuf.Empty) + Some(core_request::Payload::RegisterMobileAuth(request)) => { + match context + .enrollment_server + .register_mobile_auth(request) + .await + { + Ok(()) => Some(core_response::Payload::Empty(())), + Err(err) => { + error!("Register mobile auth error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } - // rpc RequestPasswordReset (PasswordResetInitializeRequest) returns (google.protobuf.Empty) - Some(core_request::Payload::PasswordResetInit(request)) => { - match password_reset_server - .request_password_reset(request, received.device_info) - .await - { - Ok(()) => Some(core_response::Payload::Empty(())), - Err(err) => { - error!("password reset init error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + // rpc StartEnrollment (EnrollmentStartRequest) returns (EnrollmentStartResponse) + Some(core_request::Payload::EnrollmentStart(request)) => { + match context + .enrollment_server + .start_enrollment(request, received.device_info) + .await + { + Ok(response_payload) => { + Some(core_response::Payload::EnrollmentStart(response_payload)) + } + Err(err) => { + error!("start enrollment error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } - // rpc StartPasswordReset (PasswordResetStartRequest) returns (PasswordResetStartResponse) - Some(core_request::Payload::PasswordResetStart(request)) => { - match password_reset_server - .start_password_reset(request, received.device_info) - .await - { - Ok(response_payload) => Some( - core_response::Payload::PasswordResetStart(response_payload), - ), - Err(err) => { - error!("password reset start error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + // rpc ActivateUser (ActivateUserRequest) returns (google.protobuf.Empty) + Some(core_request::Payload::ActivateUser(request)) => { + match context + .enrollment_server + .activate_user(request, received.device_info) + .await + { + Ok(()) => Some(core_response::Payload::Empty(())), + Err(err) => { + error!("activate user error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } - // rpc ResetPassword (PasswordResetRequest) returns (google.protobuf.Empty) - Some(core_request::Payload::PasswordReset(request)) => { - match password_reset_server - .reset_password(request, received.device_info) - .await - { - Ok(()) => Some(core_response::Payload::Empty(())), - Err(err) => { - error!("password reset error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + // rpc CreateDevice (NewDevice) returns (DeviceConfigResponse) + Some(core_request::Payload::NewDevice(request)) => { + match context + .enrollment_server + .create_device(request, received.device_info) + .await + { + Ok(response_payload) => { + Some(core_response::Payload::DeviceConfig(response_payload)) + } + Err(err) => { + error!("create device error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } - // rpc ClientMfaStart (ClientMfaStartRequest) returns (ClientMfaStartResponse) - Some(core_request::Payload::ClientMfaStart(request)) => { - match client_mfa_server.start_client_mfa_login(request).await { - Ok(response_payload) => { - Some(core_response::Payload::ClientMfaStart(response_payload)) - } - Err(err) => { - error!("client MFA start error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + // rpc GetNetworkInfo (ExistingDevice) returns (DeviceConfigResponse) + Some(core_request::Payload::ExistingDevice(request)) => { + match context.enrollment_server.get_network_info(request).await { + Ok(response_payload) => { + Some(core_response::Payload::DeviceConfig(response_payload)) + } + Err(err) => { + error!("get network info error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } - // rpc ClientMfaFinish (ClientMfaFinishRequest) returns (ClientMfaFinishResponse) - Some(core_request::Payload::ClientMfaFinish(request)) => { - match client_mfa_server - .finish_client_mfa_login(request, received.device_info) - .await - { - Ok(response_payload) => { - Some(core_response::Payload::ClientMfaFinish(response_payload)) - } - Err(err) => { - match err.code() { - Code::FailedPrecondition => { - // User not yet done with OIDC authentication. Don't log it as an error. - debug!("Client MFA finish error: {err}"); - } - _ => { - // Log other errors as errors. - error!("Client MFA finish error: {err}"); - } + } + // rpc RequestPasswordReset (PasswordResetInitializeRequest) returns (google.protobuf.Empty) + Some(core_request::Payload::PasswordResetInit(request)) => { + match context + .password_reset_server + .request_password_reset(request, received.device_info) + .await + { + Ok(()) => Some(core_response::Payload::Empty(())), + Err(err) => { + error!("password reset init error {err}"); + Some(core_response::Payload::CoreError(err.into())) + } + } + } + // rpc StartPasswordReset (PasswordResetStartRequest) returns (PasswordResetStartResponse) + Some(core_request::Payload::PasswordResetStart(request)) => { + match context + .password_reset_server + .start_password_reset(request, received.device_info) + .await + { + Ok(response_payload) => { + Some(core_response::Payload::PasswordResetStart(response_payload)) + } + Err(err) => { + error!("password reset start error {err}"); + Some(core_response::Payload::CoreError(err.into())) + } + } + } + // rpc ResetPassword (PasswordResetRequest) returns (google.protobuf.Empty) + Some(core_request::Payload::PasswordReset(request)) => { + match context + .password_reset_server + .reset_password(request, received.device_info) + .await + { + Ok(()) => Some(core_response::Payload::Empty(())), + Err(err) => { + error!("password reset error {err}"); + Some(core_response::Payload::CoreError(err.into())) + } + } + } + // rpc ClientMfaStart (ClientMfaStartRequest) returns (ClientMfaStartResponse) + Some(core_request::Payload::ClientMfaStart(request)) => { + match context + .client_mfa_server + .start_client_mfa_login(request) + .await + { + Ok(response_payload) => { + Some(core_response::Payload::ClientMfaStart(response_payload)) + } + Err(err) => { + error!("client MFA start error {err}"); + Some(core_response::Payload::CoreError(err.into())) + } + } + } + // rpc ClientMfaFinish (ClientMfaFinishRequest) returns (ClientMfaFinishResponse) + Some(core_request::Payload::ClientMfaFinish(request)) => { + match context + .client_mfa_server + .finish_client_mfa_login(request, received.device_info) + .await + { + Ok(response_payload) => { + Some(core_response::Payload::ClientMfaFinish(response_payload)) + } + Err(err) => { + match err.code() { + Code::FailedPrecondition => { + // User not yet done with OIDC authentication. Don't log it + // as an error. + debug!("Client MFA finish error: {err}"); + } + _ => { + // Log other errors as errors. + error!("Client MFA finish error: {err}"); } - Some(core_response::Payload::CoreError(err.into())) } + Some(core_response::Payload::CoreError(err.into())) } } - Some(core_request::Payload::ClientMfaOidcAuthenticate(request)) => { - match client_mfa_server - .auth_mfa_session_with_oidc(request, received.device_info) - .await - { - Ok(()) => Some(core_response::Payload::Empty(())), - Err(err) => { - error!("client MFA OIDC authenticate error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + Some(core_request::Payload::ClientMfaOidcAuthenticate(request)) => { + match context + .client_mfa_server + .auth_mfa_session_with_oidc(request, received.device_info) + .await + { + Ok(()) => Some(core_response::Payload::Empty(())), + Err(err) => { + error!("client MFA OIDC authenticate error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } - // rpc LocationInfo (LocationInfoRequest) returns (LocationInfoResponse) - Some(core_request::Payload::InstanceInfo(request)) => { - match polling_server.info(request).await { - Ok(response_payload) => { - Some(core_response::Payload::InstanceInfo(response_payload)) - } - Err(err) => { - if Code::FailedPrecondition == err.code() { - // Ignore the case when we are not enterprise but the client is trying to fetch the instance config, - // to avoid spamming the logs with misleading errors. - - debug!( - "A client tried to fetch the instance config, but we are not enterprise." - ); - Some(core_response::Payload::CoreError(err.into())) - } else { - error!("Instance info error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + // rpc LocationInfo (LocationInfoRequest) returns (LocationInfoResponse) + Some(core_request::Payload::InstanceInfo(request)) => { + match context.polling_server.info(request).await { + Ok(response_payload) => { + Some(core_response::Payload::InstanceInfo(response_payload)) + } + Err(err) => { + if Code::FailedPrecondition == err.code() { + // Ignore the case when we are not enterprise but the client is + // trying to fetch the instance config, + // to avoid spamming the logs with misleading errors. + + debug!( + "A client tried to fetch the instance config, but we are \ + not enterprise." + ); + Some(core_response::Payload::CoreError(err.into())) + } else { + error!("Instance info error {err}"); + Some(core_response::Payload::CoreError(err.into())) } } } - Some(core_request::Payload::AuthInfo(request)) => { - if !is_enterprise_enabled() { - warn!("Enterprise license required"); - Some(core_response::Payload::CoreError(CoreError { - status_code: Code::FailedPrecondition as i32, - message: "no valid license".into(), - })) - } else if let Ok(redirect_url) = Url::parse(&request.redirect_url) { - if let Some(provider) = OpenIdProvider::get_current(&pool).await? { - if let Ok((_client_id, client)) = - make_oidc_client(redirect_url, &provider).await + } + Some(core_request::Payload::AuthInfo(request)) => { + if !is_enterprise_enabled() { + warn!("Enterprise license required"); + Some(core_response::Payload::CoreError(CoreError { + status_code: Code::FailedPrecondition as i32, + message: "no valid license".into(), + })) + } else if let Ok(redirect_url) = Url::parse(&request.redirect_url) { + if let Some(provider) = OpenIdProvider::get_current(&pool).await? { + if let Ok((_client_id, client)) = + make_oidc_client(redirect_url, &provider).await + { + let mut authorize_url_builder = client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + || build_state(request.state), + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())); + + if SELECT_ACCOUNT_SUPPORTED_PROVIDERS + .iter() + .all(|p| p.eq_ignore_ascii_case(&provider.name)) { - let (url, csrf_token, nonce) = client - .authorize_url( - CoreAuthenticationFlow::AuthorizationCode, - || build_state(request.state), - Nonce::new_random, - ) - .add_scope(Scope::new("email".to_string())) - .add_scope(Scope::new("profile".to_string())) - .url(); - Some(core_response::Payload::AuthInfo(AuthInfoResponse { - url: url.into(), - csrf_token: csrf_token.secret().to_owned(), - nonce: nonce.secret().to_owned(), - button_display_name: provider.display_name, - })) - } else { - Some(core_response::Payload::CoreError(CoreError { - status_code: Code::Internal as i32, - message: "failed to build OIDC client".into(), - })) + authorize_url_builder = authorize_url_builder.add_prompt( + openidconnect::core::CoreAuthPrompt::SelectAccount, + ); } + let (url, csrf_token, nonce) = authorize_url_builder.url(); + + Some(core_response::Payload::AuthInfo(AuthInfoResponse { + url: url.into(), + csrf_token: csrf_token.secret().to_owned(), + nonce: nonce.secret().to_owned(), + button_display_name: provider.display_name, + })) } else { - error!("Failed to get current OpenID provider"); Some(core_response::Payload::CoreError(CoreError { status_code: Code::Internal as i32, - message: "failed to get current OpenID provider".into(), + message: "failed to build OIDC client".into(), })) } } else { + error!("Failed to get current OpenID provider"); Some(core_response::Payload::CoreError(CoreError { status_code: Code::Internal as i32, - message: "invalid redirect URL".into(), + message: "failed to get current OpenID provider".into(), })) } + } else { + Some(core_response::Payload::CoreError(CoreError { + status_code: Code::Internal as i32, + message: "invalid redirect URL".into(), + })) } - Some(core_request::Payload::AuthCallback(request)) => { - match Url::parse(&request.callback_url) { - Ok(callback_url) => { - let code = AuthorizationCode::new(request.code); - match user_from_claims( - &pool, - Nonce::new(request.nonce), - code, - callback_url, - ) - .await - { - Ok(mut user) => { - user.clear_unused_enrollment_tokens(&pool).await?; - if let Err(err) = sync_user_groups_if_configured( - &user, - &pool, - &wireguard_tx, - ) - .await - { - error!( - "Failed to sync user groups for user {} with the directory while the user was logging in through an external provider: {err:?}", - user.username, - ); - } else { - ldap_update_user_state(&mut user, &pool).await; - } - debug!("Cleared unused tokens for {}.", user.username); - debug!( - "Creating a new desktop activation token for user {} as a result of proxy OpenID auth callback.", - user.username - ); - let config = server_config(); - let desktop_configuration = Token::new( - user.id, - Some(user.id), - Some(user.email), - config.enrollment_token_timeout.as_secs(), - Some(ENROLLMENT_TOKEN_TYPE.to_string()), - ); - debug!("Saving a new desktop configuration token..."); - desktop_configuration.save(&pool).await?; - debug!( - "Saved desktop configuration token. Responding to proxy with the token." + } + Some(core_request::Payload::AuthCallback(request)) => { + match Url::parse(&request.callback_url) { + Ok(callback_url) => { + let code = AuthorizationCode::new(request.code); + match user_from_claims( + &pool, + Nonce::new(request.nonce), + code, + callback_url, + ) + .await + { + Ok(mut user) => { + user.clear_unused_enrollment_tokens(&pool).await?; + if let Err(err) = sync_user_groups_if_configured( + &user, + &pool, + &context.wireguard_tx, + ) + .await + { + error!( + "Failed to sync user groups for user {} with the \ + directory while the user was logging in through an \ + external provider: {err:?}", + user.username, ); - - Some(core_response::Payload::AuthCallback( - AuthCallbackResponse { - url: config.enrollment_url.clone().into(), - token: desktop_configuration.id, - }, - )) - } - Err(err) => { - let message = format!("OpenID auth error {err}"); - error!(message); - Some(core_response::Payload::CoreError(CoreError { - status_code: Code::Internal as i32, - message, - })) + } else { + ldap_update_user_state(&mut user, &pool).await; } + debug!("Cleared unused tokens for {}.", user.username); + debug!( + "Creating a new desktop activation token for user {} \ + as a result of proxy OpenID auth callback.", + user.username + ); + let config = server_config(); + let desktop_configuration = Token::new( + user.id, + Some(user.id), + Some(user.email), + config.enrollment_token_timeout.as_secs(), + Some(ENROLLMENT_TOKEN_TYPE.to_string()), + ); + debug!("Saving a new desktop configuration token..."); + desktop_configuration.save(&pool).await?; + debug!( + "Saved desktop configuration token. Responding to \ + proxy with the token." + ); + + Some(core_response::Payload::AuthCallback( + AuthCallbackResponse { + url: config.enrollment_url.clone().into(), + token: desktop_configuration.id, + }, + )) + } + Err(err) => { + let message = format!("OpenID auth error {err}"); + error!(message); + Some(core_response::Payload::CoreError(CoreError { + status_code: Code::Internal as i32, + message, + })) } - } - Err(err) => { - error!( - "Proxy requested an OpenID authentication info for a callback URL ({}) that couldn't be parsed. Details: {err}", - request.callback_url - ); - Some(core_response::Payload::CoreError(CoreError { - status_code: Code::Internal as i32, - message: "invalid callback URL".into(), - })) } } + Err(err) => { + error!( + "Proxy requested an OpenID authentication info for a callback \ + URL ({}) that couldn't be parsed. Details: {err}", + request.callback_url + ); + Some(core_response::Payload::CoreError(CoreError { + status_code: Code::Internal as i32, + message: "invalid callback URL".into(), + })) + } } - // Reply without payload. - None => None, - }; - let req = CoreResponse { - id: received.id, - payload, - }; - tx.send(req).unwrap(); - } - Err(err) => { - error!("Disconnected from proxy at {}", endpoint.uri()); - error!("stream error: {err}"); - debug!("waiting 10s to re-establish the connection"); - sleep(TEN_SECS).await; - break 'message; + } + // Reply without payload. + None => None, + }; + let req = CoreResponse { + id: received.id, + payload, + }; + context.tx.send(req).unwrap(); + } + Err(err) => { + error!("Disconnected from proxy at {}: {err}", context.endpoint_uri); + debug!("waiting 10s to re-establish the connection"); + sleep(TEN_SECS).await; + break 'message; + } + } + } + + Ok(()) +} + +/// Bi-directional gRPC stream for communication with Defguard Proxy. +#[instrument(skip_all)] +pub async fn run_grpc_bidi_stream( + pool: PgPool, + wireguard_tx: Sender, + mail_tx: UnboundedSender, + bidi_event_tx: UnboundedSender, + incompatible_components: Arc>, +) -> Result<(), anyhow::Error> { + let config = server_config(); + + // TODO: merge the two + let mut enrollment_server = EnrollmentServer::new( + pool.clone(), + wireguard_tx.clone(), + mail_tx.clone(), + bidi_event_tx.clone(), + ); + let mut password_reset_server = + PasswordResetServer::new(pool.clone(), mail_tx.clone(), bidi_event_tx.clone()); + let mut client_mfa_server = + ClientMfaServer::new(pool.clone(), mail_tx, wireguard_tx.clone(), bidi_event_tx); + let mut polling_server = PollingServer::new(pool.clone()); + + let endpoint = Endpoint::from_shared(config.proxy_url.as_deref().unwrap())?; + let endpoint = endpoint + .http2_keep_alive_interval(TEN_SECS) + .tcp_keepalive(Some(TEN_SECS)) + .keep_alive_while_idle(true); + let endpoint = if let Some(ca) = &config.proxy_grpc_ca { + let ca = read_to_string(ca)?; + let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca)); + endpoint.tls_config(tls)? + } else { + endpoint.tls_config(ClientTlsConfig::new().with_enabled_roots())? + }; + + loop { + debug!("Connecting to proxy at {}", endpoint.uri()); + let interceptor = ClientVersionInterceptor::new(Version::parse(VERSION)?); + let mut client = ProxyClient::with_interceptor(endpoint.connect_lazy(), interceptor); + let (tx, rx) = mpsc::unbounded_channel(); + let response = match client.bidi(UnboundedReceiverStream::new(rx)).await { + Ok(response) => response, + Err(err) => { + match err.code() { + Code::FailedPrecondition => { + error!( + "Failed to connect to proxy @ {}, version check failed, retrying in \ + 10s: {err}", + endpoint.uri() + ); + // TODO push event + } + err => { + error!( + "Failed to connect to proxy @ {}, retrying in 10s: {err}", + endpoint.uri() + ); + } } + sleep(TEN_SECS).await; + continue; } + }; + let maybe_info = ComponentInfo::from_metadata(response.metadata()); + + // Check proxy version and continue if it's not supported. + let (version, info) = get_tracing_variables(&maybe_info); + let proxy_is_supported = is_proxy_version_supported(Some(&version)); + + let span = tracing::info_span!("proxy_bidi", component = %DefguardComponent::Proxy, + version = version.to_string(), info); + let _guard = span.enter(); + if !proxy_is_supported { + // Store incompatible proxy + let maybe_version = if version == VERSION_ZERO { + None + } else { + Some(version) + }; + let data = IncompatibleProxyData::new(maybe_version); + data.insert(&incompatible_components); + + // Sleep before trying to reconnect + sleep(TEN_SECS).await; + continue; + } else { + IncompatibleComponents::remove_proxy(&incompatible_components); } + + info!("Connected to proxy at {}", endpoint.uri()); + let mut resp_stream = response.into_inner(); + handle_proxy_message_loop(ProxyMessageLoopContext { + pool: pool.clone(), + tx, + wireguard_tx: wireguard_tx.clone(), + resp_stream: &mut resp_stream, + enrollment_server: &mut enrollment_server, + password_reset_server: &mut password_reset_server, + client_mfa_server: &mut client_mfa_server, + polling_server: &mut polling_server, + endpoint_uri: endpoint.uri(), + }) + .await?; } } @@ -844,57 +677,112 @@ pub async fn run_grpc_server( worker_state: Arc>, pool: PgPool, gateway_state: Arc>, + client_state: Arc>, wireguard_tx: Sender, mail_tx: UnboundedSender, grpc_cert: Option, grpc_key: Option, failed_logins: Arc>, grpc_event_tx: UnboundedSender, + incompatible_components: Arc>, ) -> Result<(), anyhow::Error> { // Build gRPC services + let server = if let (Some(cert), Some(key)) = (grpc_cert, grpc_key) { + let identity = Identity::from_pem(cert, key); + Server::builder().tls_config(ServerTlsConfig::new().identity(identity))? + } else { + Server::builder() + }; + + let router = build_grpc_service_router( + server, + pool, + worker_state, + gateway_state, + client_state, + wireguard_tx, + mail_tx, + failed_logins, + grpc_event_tx, + incompatible_components, + ) + .await?; + + // Run gRPC server + let addr = SocketAddr::new( + server_config() + .grpc_bind_address + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), + server_config().grpc_port, + ); + debug!("Starting gRPC services"); + router.serve(addr).await?; + info!("gRPC server started on {addr}"); + Ok(()) +} + +pub async fn build_grpc_service_router( + server: Server, + pool: PgPool, + worker_state: Arc>, + gateway_state: Arc>, + client_state: Arc>, + wireguard_tx: Sender, + mail_tx: UnboundedSender, + failed_logins: Arc>, + grpc_event_tx: UnboundedSender, + incompatible_components: Arc>, +) -> Result { let auth_service = AuthServiceServer::new(AuthServer::new(pool.clone(), failed_logins)); + #[cfg(feature = "worker")] let worker_service = WorkerServiceServer::with_interceptor( WorkerServer::new(pool.clone(), worker_state), JwtInterceptor::new(ClaimsType::YubiBridge), ); - #[cfg(feature = "wireguard")] - let gateway_service = GatewayServiceServer::with_interceptor( - GatewayServer::new(pool, gateway_state, wireguard_tx, mail_tx, grpc_event_tx), - JwtInterceptor::new(ClaimsType::Gateway), - ); - let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); + let (health_reporter, health_service) = tonic_health::server::health_reporter(); health_reporter .set_serving::>() .await; - // Run gRPC server - let addr = SocketAddr::new( - server_config() - .grpc_bind_address - .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), - server_config().grpc_port, - ); - debug!("Starting gRPC services"); - let builder = if let (Some(cert), Some(key)) = (grpc_cert, grpc_key) { - let identity = Identity::from_pem(cert, key); - Server::builder().tls_config(ServerTlsConfig::new().identity(identity))? - } else { - Server::builder() - }; - let router = builder + let router = server .http2_keepalive_interval(Some(TEN_SECS)) .tcp_keepalive(Some(TEN_SECS)) .add_service(health_service) .add_service(auth_service); + #[cfg(feature = "wireguard")] - let router = router.add_service(gateway_service); + let router = { + use crate::version::GatewayVersionInterceptor; + + let gateway_service = GatewayServiceServer::new(GatewayServer::new( + pool, + gateway_state, + client_state, + wireguard_tx, + mail_tx, + grpc_event_tx, + )); + + let own_version = Version::parse(VERSION)?; + router.add_service( + ServiceBuilder::new() + .layer(tonic::service::InterceptorLayer::new(JwtInterceptor::new( + ClaimsType::Gateway, + ))) + .layer(tonic::service::InterceptorLayer::new( + GatewayVersionInterceptor::new(MIN_GATEWAY_VERSION, incompatible_components), + )) + .layer(DefguardVersionLayer::new(own_version)) + .service(gateway_service), + ) + }; + #[cfg(feature = "worker")] let router = router.add_service(worker_service); - router.serve(addr).await?; - info!("gRPC server started on {addr}"); - Ok(()) + + Ok(router) } #[cfg(feature = "worker")] diff --git a/crates/defguard_core/src/handlers/activity_log.rs b/crates/defguard_core/src/handlers/activity_log.rs index 8a5a631ba1..c14a68fa88 100644 --- a/crates/defguard_core/src/handlers/activity_log.rs +++ b/crates/defguard_core/src/handlers/activity_log.rs @@ -118,7 +118,7 @@ pub struct ApiActivityLogEvent { // TODO: add utoipa API schema /// Filtered list of activity log events /// -/// Retrives a paginated list of activity log events filtered by following query parameters: +/// Retrieves a paginated list of activity log events filtered by following query parameters: /// TODO: add explanations /// - from /// - until @@ -159,9 +159,9 @@ pub async fn get_activity_log_events( // add limit and offset to fetch a specific page let limit = DEFAULT_API_PAGE_SIZE; - query_builder.push(" LIMIT ").push_bind(limit as i64); + query_builder.push(" LIMIT ").push_bind(i64::from(limit)); let offset = (pagination.page - 1) * DEFAULT_API_PAGE_SIZE; - query_builder.push(" OFFSET ").push_bind(offset as i64); + query_builder.push(" OFFSET ").push_bind(i64::from(offset)); // fetch filtered events let events = query_builder diff --git a/crates/defguard_core/src/handlers/app_info.rs b/crates/defguard_core/src/handlers/app_info.rs index 7ba365a774..bc67e8c17f 100644 --- a/crates/defguard_core/src/handlers/app_info.rs +++ b/crates/defguard_core/src/handlers/app_info.rs @@ -1,12 +1,14 @@ use axum::{extract::State, http::StatusCode}; use serde_json::json; -use super::{ApiResponse, ApiResult, VERSION}; +use super::{ApiResponse, ApiResult}; use crate::{ + VERSION, appstate::AppState, auth::SessionInfo, db::{Settings, WireguardNetwork}, enterprise::{ + db::models::openid_provider::OpenIdProvider, is_enterprise_enabled, is_enterprise_free, license::get_cached_license, limits::{LimitsExceeded, get_counts}, @@ -41,19 +43,24 @@ pub struct AppInfo { smtp_enabled: bool, license_info: LicenseInfo, ldap_info: LdapInfo, + external_openid_enabled: bool, } pub(crate) async fn get_app_info( State(appstate): State, _session: SessionInfo, ) -> ApiResult { + // both `await`s are executed upfront to avoid holding license `RwLock` across an await point let networks = WireguardNetwork::all(&appstate.pool).await?; + let external_openid_enabled = OpenIdProvider::get_current(&appstate.pool).await?.is_some(); + let settings = Settings::get_current_settings(); let enterprise = is_enterprise_enabled(); let license = get_cached_license(); let counts = get_counts(); let limits_exceeded = counts.get_exceeded_limits(license.as_ref()); let any_limit_exceeded = limits_exceeded.any(); + let res = AppInfo { network_present: !networks.is_empty(), smtp_enabled: settings.smtp_configured(), @@ -68,6 +75,7 @@ pub(crate) async fn get_app_info( enabled: settings.ldap_enabled, ad: settings.ldap_uses_ad, }, + external_openid_enabled, }; Ok(ApiResponse::new(json!(res), StatusCode::OK)) diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 6924f5c760..4e629ee746 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -137,53 +137,34 @@ pub(crate) async fn authenticate( ) -> Result<(CookieJar, PrivateCookieJar, ApiResponse), WebError> { let username_or_email = data.username; debug!("Authenticating user {username_or_email}"); + // check if user can proceed with login check_failed_logins(&appstate.failed_logins, &username_or_email)?; + let settings = Settings::get_current_settings(); // attempt to find user first by username and then by email let mut conn = appstate.pool.acquire().await?; - let mut user = match User::find_by_username_or_email(&mut conn, &username_or_email).await? { - Some(user) => { - // user was found, attempt to authenticate by password first - match user.verify_password(&data.password) { - Ok(()) => user, - Err(err) => { - // password authentication failed, try authenticating with LDAP if configured - if settings.ldap_enabled { - match login_through_ldap(&appstate.pool, &username_or_email, &data.password) - .await - { - Ok(user) => user, - Err(ldap_err) => { - warn!( - "Failed to authenticate user {username_or_email} internally and through LDAP. Internal error: {err}, LDAP error: {ldap_err}" - ); - - log_failed_login_attempt( - &appstate.failed_logins, - &username_or_email, - ); - appstate.emit_event(ApiEvent { - context: ApiRequestContext::new( - user.id, - user.username, - insecure_ip, - user_agent.to_string(), - ), - event: Box::new(ApiEventType::UserLoginFailed { - message: format!( - "Internal and LDAP authentication for {username_or_email} failed. Internal error: {err}, LDAP error: {ldap_err}" - ), - }), - })?; - return Err(WebError::Authorization(ldap_err.to_string())); - } - } - } else { - warn!("Failed to authenticate user {username_or_email}: {err}"); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); - appstate.emit_event(ApiEvent { + let mut user = if let Some(user) = + User::find_by_username_or_email(&mut conn, &username_or_email).await? + { + // user was found, attempt to authenticate by password first + match user.verify_password(&data.password) { + Ok(()) => user, + Err(err) => { + // password authentication failed, try authenticating with LDAP if configured + if settings.ldap_enabled { + match login_through_ldap(&appstate.pool, &username_or_email, &data.password) + .await + { + Ok(user) => user, + Err(ldap_err) => { + warn!( + "Failed to authenticate user {username_or_email} internally and through LDAP. Internal error: {err}, LDAP error: {ldap_err}" + ); + + log_failed_login_attempt(&appstate.failed_logins, &user.username); + appstate.emit_event(ApiEvent { context: ApiRequestContext::new( user.id, user.username, @@ -192,25 +173,42 @@ pub(crate) async fn authenticate( ), event: Box::new(ApiEventType::UserLoginFailed { message: format!( - "Authentication for {username_or_email} failed: {err}" + "Internal and LDAP authentication for {username_or_email} failed. Internal error: {err}, LDAP error: {ldap_err}" ), }), })?; - return Err(WebError::Authorization(err.to_string())); + return Err(WebError::Authorization(ldap_err.to_string())); + } } + } else { + warn!("Failed to authenticate user {username_or_email}: {err}"); + log_failed_login_attempt(&appstate.failed_logins, &user.username); + appstate.emit_event(ApiEvent { + context: ApiRequestContext::new( + user.id, + user.username, + insecure_ip, + user_agent.to_string(), + ), + event: Box::new(ApiEventType::UserLoginFailed { + message: format!( + "Authentication for {username_or_email} failed: {err}" + ), + }), + })?; + return Err(WebError::Authorization(err.to_string())); } } } - None => { - // try to create user from LDAP - debug!("User not found in DB, authenticating user {username_or_email} with LDAP"); - match login_through_ldap(&appstate.pool, &username_or_email, &data.password).await { - Ok(user) => user, - Err(err) => { - info!("Failed to authenticate user {username_or_email} with LDAP: {err}"); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); - return Err(WebError::Authorization(err.to_string())); - } + } else { + // try to create user from LDAP + debug!("User not found in DB, authenticating user {username_or_email} with LDAP"); + match login_through_ldap(&appstate.pool, &username_or_email, &data.password).await { + Ok(user) => user, + Err(err) => { + info!("Failed to authenticate user {username_or_email} with LDAP: {err}"); + log_failed_login_attempt(&appstate.failed_logins, &username_or_email); + return Err(WebError::Authorization(err.to_string())); } } }; @@ -694,6 +692,9 @@ pub async fn totp_code( ) -> Result<(PrivateCookieJar, ApiResponse), WebError> { if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { let username = user.username.clone(); + // check if user can proceed with login + check_failed_logins(&appstate.failed_logins, &username)?; + debug!("Verifying TOTP for user {}", username); if user.totp_enabled && user.verify_totp_code(&data.code) { session @@ -748,6 +749,8 @@ pub async fn totp_code( format!("TOTP authentication is disabled for {username}") }; + log_failed_login_attempt(&appstate.failed_logins, &username); + appstate.emit_event(ApiEvent { // User may not be fully authenticated so we can't use // context extractor in this handler since it requires @@ -786,7 +789,7 @@ pub async fn email_mfa_init(session: SessionInfo, State(appstate): State Result<(PrivateCookieJar, ApiResponse), WebError> { if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { let username = user.username.clone(); + + // check if user can proceed with login + check_failed_logins(&appstate.failed_logins, &username)?; + debug!("Verifying email MFA code for user {}", username); if user.email_mfa_enabled && user.verify_email_mfa_code(&data.code) { session @@ -898,7 +905,7 @@ pub async fn email_mfa_code( }), })?; if let Some(openid_cookie) = private_cookies.get(SIGN_IN_COOKIE_NAME) { - debug!("Found openid session cookie."); + debug!("Found OpenID session cookie."); let redirect_url = openid_cookie.value().to_string(); let private_cookies = private_cookies.remove(openid_cookie); Ok(( @@ -930,6 +937,8 @@ pub async fn email_mfa_code( format!("Email code authentication is disabled for {username}") }; + log_failed_login_attempt(&appstate.failed_logins, &username); + appstate.emit_event(ApiEvent { // User may not be fully authenticated so we can't use // context extractor in this handler since it requires diff --git a/crates/defguard_core/src/handlers/group.rs b/crates/defguard_core/src/handlers/group.rs index ee0b7b08cf..192c5e8358 100644 --- a/crates/defguard_core/src/handlers/group.rs +++ b/crates/defguard_core/src/handlers/group.rs @@ -8,7 +8,7 @@ use serde_json::json; use sqlx::query_as; use utoipa::ToSchema; -use super::{ApiResponse, EditGroupInfo, GroupInfo, Username}; +use super::{ApiResponse, ApiResult, EditGroupInfo, GroupInfo, Username}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, @@ -45,10 +45,10 @@ pub(crate) struct BulkAssignToGroupsRequest { /// Bulk assign users to groups /// -/// Assign many users to many groups at once. +/// Assign many users to many groups at once basing on `BulkAssignToGroupsRequest` object. /// /// # Returns -/// If error occurs, it returns `WebError` object. +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/groups-assign", @@ -122,7 +122,11 @@ pub(crate) async fn bulk_assign_to_groups( ldap_add_users_to_groups(ldap_user_groups, &appstate.pool).await; let users_to_maybe_update = users.iter_mut().collect::>(); - ldap_update_users_state(users_to_maybe_update, &appstate.pool).await; + Box::pin(ldap_update_users_state( + users_to_maybe_update, + &appstate.pool, + )) + .await; info!("Assigned {} groups to {} users.", groups.len(), users.len()); appstate.emit_event(ApiEvent { @@ -138,12 +142,14 @@ pub(crate) async fn bulk_assign_to_groups( /// Retrieve all groups info /// -/// For each group, the endpoint retrieves a `GroupInfo` object containing: group name, a list of members' usernames and a list of vpn_location. +/// For each group, the endpoint retrieves a `GroupInfo` object containing: group name, a list of members usernames and a list of vpn_location. /// -/// `There is another endpoint "/api/v1/group" that retrives only name of each groups if you don't want all information.` +/// **There is another endpoint "/api/v1/group" that retrieves only name of each groups if you don't want all information.** /// /// # Returns -/// Returns a list of `GroupInfo` objects or `WebError` if error occurs. +/// - `GroupInfo` object +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/group-info", @@ -168,7 +174,7 @@ pub(crate) async fn bulk_assign_to_groups( pub(crate) async fn list_groups_info( _role: AdminRole, State(appstate): State, -) -> Result { +) -> ApiResult { debug!("Listing groups info"); let q_result = query_as!( GroupInfo, @@ -193,15 +199,19 @@ pub(crate) async fn list_groups_info( /// Retrieve all groups. /// +/// Retrieves group details by `name`. +/// /// # Returns -/// Returns a `Groups` object or `WebError` if error occurs. +/// - `Groups` object +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/group", responses( (status = 200, description = "Retrieve all groups.", body = Groups, example = json!({"groups": ["admin"]})), - (status = 401, description = "Unauthorized to retrive all groups.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 500, description = "Cannot retrive all groups.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 401, description = "Unauthorized to retrieve all groups.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 500, description = "Cannot retrieve all groups.", body = ApiResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -209,26 +219,29 @@ pub(crate) async fn list_groups_info( ) )] pub(crate) async fn list_groups( - _session: SessionInfo, + _admin: AdminRole, + session: SessionInfo, State(appstate): State, -) -> Result { - debug!("Listing groups"); +) -> ApiResult { + debug!("User {} lists groups", &session.user.username); let groups = Group::all(&appstate.pool) .await? .into_iter() .map(|group| group.name) .collect(); - info!("Listed groups"); + info!("User {} listed groups", &session.user.username); Ok(ApiResponse { json: json!(Groups::new(groups)), status: StatusCode::OK, }) } -/// Retrieve group with `name`. +/// Retrieve group with name /// /// # Returns -/// Returns a `GroupInfo` object or `WebError` if error occurs. +/// - `GroupInfo` object +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/group/{name}", @@ -244,9 +257,9 @@ pub(crate) async fn list_groups( "is_admin": false } )), - (status = 401, description = "Unauthorized to retrive a group.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 401, description = "Unauthorized to retrieve a group.", body = ApiResponse, example = json!({"msg": "Session is required"})), (status = 404, description = "Incorrect name of the group.", body = ApiResponse, example = json!({"msg": "Group not found"})), - (status = 500, description = "Cannot retrive a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 500, description = "Cannot retrieve a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -254,10 +267,11 @@ pub(crate) async fn list_groups( ) )] pub(crate) async fn get_group( + _admin: AdminRole, _session: SessionInfo, State(appstate): State, Path(name): Path, -) -> Result { +) -> ApiResult { debug!("Retrieving group {name}"); if let Some(group) = Group::find_by_name(&appstate.pool, &name).await? { let members = group.member_usernames(&appstate.pool).await?; @@ -285,10 +299,14 @@ pub(crate) async fn get_group( /// Create group /// -/// Create group with a given name and member list. +/// Create group based on `EditGroupInfo` object. +/// +/// You can also choose whether group should grant admin privileges by changing `is_admin` parameter. /// /// # Returns -/// Returns a `GroupsInfo` object or `WebError` if error occurs. +/// - `EditGroupInfo` object +/// +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/group", @@ -300,10 +318,10 @@ pub(crate) async fn get_group( "members": ["user"] } )), - (status = 401, description = "Unauthorized to retrive a group.", body = ApiResponse, example = json!({"msg": "Session is required"})), + (status = 401, description = "Unauthorized to retrieve a group.", body = ApiResponse, example = json!({"msg": "Session is required"})), (status = 403, description = "You don't have permission to list groups info.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), (status = 404, description = "Cannot create group: user don't exist.", body = ApiResponse, example = json!({"msg": "Failed to find user "})), - (status = 500, description = "Cannot retrive a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 500, description = "Cannot retrieve a group.", body = ApiResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -315,7 +333,7 @@ pub(crate) async fn create_group( State(appstate): State, context: ApiRequestContext, Json(group_info): Json, -) -> Result { +) -> ApiResult { debug!("Creating group {}", group_info.name); let mut ldap_user_groups: HashMap<&User, HashSet<&str>> = HashMap::new(); @@ -338,7 +356,7 @@ pub(crate) async fn create_group( } } - for user in members.iter() { + for user in &members { user.add_to_group(&mut *transaction, &group).await?; ldap_user_groups .entry(user) @@ -353,7 +371,11 @@ pub(crate) async fn create_group( if !ldap_user_groups.is_empty() { ldap_add_users_to_groups(ldap_user_groups, &appstate.pool).await; let users_to_maybe_update = members.iter_mut().collect::>(); - ldap_update_users_state(users_to_maybe_update, &appstate.pool).await; + Box::pin(ldap_update_users_state( + users_to_maybe_update, + &appstate.pool, + )) + .await; } info!("Created group {}", group_info.name); @@ -370,10 +392,14 @@ pub(crate) async fn create_group( /// Modify group /// -/// Rename group and/or change group members. +/// Rename group and change members basing on `EditGroupInfo` object. +/// +/// You can also change `is_admin` parameter if you want to grant admin privileges to group members. /// /// # Returns -/// Returns a `GroupsInfo` object or `WebError` if error occurs. +/// - empty JSON +/// +/// - `WebError` if error occurs #[utoipa::path( put, path = "/api/v1/group/{name}", @@ -396,7 +422,7 @@ pub(crate) async fn modify_group( context: ApiRequestContext, Path(name): Path, Json(group_info): Json, -) -> Result { +) -> ApiResult { debug!("Modifying group {}", group_info.name); let Some(mut group) = Group::find_by_name(&appstate.pool, &name).await? else { let msg = format!("Group {name} not found"); @@ -413,7 +439,7 @@ pub(crate) async fn modify_group( // Rename only when needed. // if group.name != group_info.name { - group.name = group_info.name.clone(); + group.name.clone_from(&group_info.name); group.save(&mut *transaction).await?; } @@ -458,7 +484,7 @@ pub(crate) async fn modify_group( } } - for user in members.iter() { + for user in &members { user.add_to_group(&mut *transaction, &group).await?; add_to_ldap_groups .entry(user) @@ -467,7 +493,7 @@ pub(crate) async fn modify_group( } // Remove outstanding members. - for user in current_members.iter() { + for user in ¤t_members { user.remove_from_group(&mut *transaction, &group).await?; remove_from_ldap_groups .entry(user) @@ -524,12 +550,12 @@ pub(crate) async fn modify_group( Ok(ApiResponse::default()) } -/// Remove group with `name`. +/// Remove group with name. /// /// Delete group and group members. /// /// # Returns -/// If error occurs it returns `WebError` object. +/// - `WebError` if error occurs #[utoipa::path( delete, path = "/api/v1/group/{name}", @@ -549,12 +575,13 @@ pub(crate) async fn modify_group( ) )] pub(crate) async fn delete_group( - _session: SessionInfo, + _admin: AdminRole, + session: SessionInfo, State(appstate): State, context: ApiRequestContext, Path(name): Path, -) -> Result { - debug!("Deleting group {name}"); +) -> ApiResult { + debug!("User {} deletes group {name}", &session.user.username); if let Some(group) = Group::find_by_name(&appstate.pool, &name).await? { // Prevent removing the last admin group if group.is_admin { @@ -576,7 +603,7 @@ pub(crate) async fn delete_group( let mut conn = appstate.pool.acquire().await?; WireguardNetwork::sync_all_networks(&mut conn, &appstate.wireguard_tx).await?; - info!("Deleted group {name}"); + info!("User {} deleted group {name}", &session.user.username); appstate.emit_event(ApiEvent { context, event: Box::new(ApiEventType::GroupRemoved { group }), @@ -594,7 +621,7 @@ pub(crate) async fn delete_group( /// Find a group with `name` and add `username` as a member. /// /// # Returns -/// If error occurs it returns `WebError` object. +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/group/{name}", @@ -620,7 +647,7 @@ pub(crate) async fn add_group_member( context: ApiRequestContext, Path(name): Path, Json(data): Json, -) -> Result { +) -> ApiResult { if let Some(group) = Group::find_by_name(&appstate.pool, &name).await? { if let Some(mut user) = User::find_by_username(&appstate.pool, &data.username).await? { debug!("Adding user: {} to group: {}", user.username, group.name); @@ -654,7 +681,7 @@ pub(crate) async fn add_group_member( /// Find a group with `name` and remove `username` as a member. /// /// # Returns -/// If error occurs it returns `WebError` object. +/// - `WebError` if error occurs #[utoipa::path( delete, path = "/api/v1/group/{name}/user/{username}", @@ -679,7 +706,7 @@ pub(crate) async fn remove_group_member( State(appstate): State, context: ApiRequestContext, Path((name, username)): Path<(String, String)>, -) -> Result { +) -> ApiResult { if let Some(group) = Group::find_by_name(&appstate.pool, &name).await? { if let Some(user) = User::find_by_username(&appstate.pool, &username).await? { debug!( diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index 994ee14b61..67aa52cddd 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -203,7 +203,7 @@ pub fn send_new_device_added_email( Ok(()) } Err(err) => { - error!("Sending new device notification to {to} failed with erorr:\n{err}"); + error!("Sending new device notification to {to} failed with error:\n{err}"); Ok(()) } } @@ -308,7 +308,7 @@ pub async fn send_new_device_login_email( info!("Sent new device login notification to {to}"); } Err(err) => { - error!("Sending new device login notification to {to} failed with erorr:\n{err}"); + error!("Sending new device login notification to {to} failed with error:\n{err}"); } } @@ -340,7 +340,7 @@ pub async fn send_new_device_ocid_login_email( info!("Sent new device OCID login notification to {to}"); } Err(err) => { - error!("Sending new device OCID login notification to {to} failed with erorr:\n{err}"); + error!("Sending new device OCID login notification to {to} failed with error:\n{err}"); } } @@ -382,7 +382,7 @@ pub fn send_mfa_configured_email( pub fn send_email_mfa_activation_email( user: &User, mail_tx: &UnboundedSender, - session: &Session, + session: Option<&Session>, ) -> Result<(), TemplateError> { debug!("Sending email MFA activation mail to {}", user.email); diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 67b71a41fb..b088716a37 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -1,7 +1,7 @@ use axum::{ Json, extract::{FromRef, FromRequestParts}, - http::{HeaderName, HeaderValue, StatusCode, request::Parts}, + http::{StatusCode, request::Parts}, response::{IntoResponse, Response}, }; use axum_client_ip::InsecureClientIp; @@ -14,7 +14,6 @@ use webauthn_rs::prelude::RegisterPublicKeyCredential; #[cfg(feature = "wireguard")] use crate::db::Device; use crate::{ - VERSION, appstate::AppState, auth::SessionInfo, db::{Id, NoId, User, UserInfo, WebHook}, @@ -219,10 +218,6 @@ impl IntoResponse for WebError { impl IntoResponse for ApiResponse { fn into_response(self) -> Response { let mut response = Json(self.json).into_response(); - response.headers_mut().insert( - HeaderName::from_static("x-defguard-version"), - HeaderValue::from_static(VERSION), - ); *response.status_mut() = self.status; response } diff --git a/crates/defguard_core/src/handlers/network_devices.rs b/crates/defguard_core/src/handlers/network_devices.rs index aafa778d08..6f85448f06 100644 --- a/crates/defguard_core/src/handlers/network_devices.rs +++ b/crates/defguard_core/src/handlers/network_devices.rs @@ -221,6 +221,19 @@ pub struct IpAvailabilityCheck { ips: Vec, } +#[derive(Serialize)] +pub struct IpAvailabilityCheckResult { + available: bool, + valid: bool, +} + +impl IpAvailabilityCheckResult { + #[must_use] + pub fn new(available: bool, valid: bool) -> Self { + Self { available, valid } + } +} + pub(crate) async fn check_ip_availability( _admin_role: AdminRole, Path(network_id): Path, @@ -228,71 +241,76 @@ pub(crate) async fn check_ip_availability( Json(check): Json, ) -> ApiResult { let mut transaction = appstate.pool.begin().await?; - let network = WireguardNetwork::find_by_id(&appstate.pool, network_id) + + // fetch relevant WireGuard location + let location = WireguardNetwork::find_by_id(&appstate.pool, network_id) .await? .ok_or_else(|| { error!( - "Failed to check IP availability for network with ID {network_id}, network not found", + "Failed to check IP availability for location with ID {network_id}, location not found", ); - WebError::BadRequest("Failed to check IP availability, network not found".into()) + WebError::BadRequest("Failed to check IP availability, location not found".into()) })?; - let ips = check - .ips - .iter() - .map(|ip| IpAddr::from_str(ip)) - .collect::, AddrParseError>>(); - let Ok(ips) = ips else { - warn!( - "Failed to check IP availability for network {}, invalid IP address", - network.name - ); - return Ok(ApiResponse { - json: json!({ - "available": false, - "valid": false, - }), - status: StatusCode::OK, - }); - }; - let mkresponse = |available: bool, valid: bool| { - Ok(ApiResponse { - json: json!({ - "available": available, - "valid": valid, - }), - status: StatusCode::OK, - }) - }; - return match network.can_assign_ips(&mut transaction, &ips, None).await { - Ok(_) => mkresponse(true, true), - Err(NetworkAddressError::NoContainingNetwork(name, ip, networks)) => { - warn!( - "Provided device IP address {ip} is not in the network {name} range: {networks:?}" - ); - mkresponse(false, false) - } - Err(NetworkAddressError::ReservedForGateway(name, ip)) => { - warn!( - "Provided device IP address {ip} may overlap with the gateway's IP address on network {name}", - ); - mkresponse(false, true) - } - Err(NetworkAddressError::IsBroadcastAddress(name, ip)) => { - warn!("Provided device IP address {ip} is broadcast address of network {name}"); - mkresponse(false, true) - } - Err(NetworkAddressError::IsNetworkAddress(name, ip)) => { - warn!("Provided device IP address {ip} is network address of network {name}"); - mkresponse(false, true) - } - Err(NetworkAddressError::AddressAlreadyAssigned(name, ip)) => { - warn!("Provided device IP {ip} is already assigned in network {name}"); - mkresponse(false, true) + // process IPs one by one and preserve order in response + let mut validation_results = Vec::new(); + for ip in &check.ips { + match IpAddr::from_str(ip) { + Ok(ip) => { + debug!( + "Checking if IP address {ip} can be assigned to a device in location {location}", + ); + let result = match location.can_assign_ips(&mut transaction, &[ip], None).await { + Ok(()) => IpAvailabilityCheckResult::new(true, true), + Err(NetworkAddressError::NoContainingNetwork(name, ip, networks)) => { + warn!( + "Provided device IP address {ip} is not in the network {name} range: {networks:?}" + ); + IpAvailabilityCheckResult::new(false, false) + } + Err(NetworkAddressError::ReservedForGateway(name, ip)) => { + warn!( + "Provided device IP address {ip} may overlap with the gateway's IP address on network {name}", + ); + IpAvailabilityCheckResult::new(false, true) + } + Err(NetworkAddressError::IsBroadcastAddress(name, ip)) => { + warn!( + "Provided device IP address {ip} is broadcast address of network {name}" + ); + IpAvailabilityCheckResult::new(false, true) + } + Err(NetworkAddressError::IsNetworkAddress(name, ip)) => { + warn!( + "Provided device IP address {ip} is network address of network {name}" + ); + IpAvailabilityCheckResult::new(false, true) + } + Err(NetworkAddressError::AddressAlreadyAssigned(name, ip)) => { + warn!("Provided device IP {ip} is already assigned in network {name}"); + IpAvailabilityCheckResult::new(false, true) + } + Err(NetworkAddressError::DbError(err)) => Err(err)?, + }; + validation_results.push(result); + } + Err(_err) => { + warn!( + "Failed to check IP availability for location {location}, invalid IP address {ip}", + ); + validation_results.push(IpAvailabilityCheckResult { + available: false, + valid: false, + }); + } } - Err(NetworkAddressError::DbError(err)) => Err(err)?, - }; + } + + Ok(ApiResponse { + json: json!(validation_results), + status: StatusCode::OK, + }) } pub(crate) async fn find_available_ips( @@ -333,16 +351,7 @@ pub(crate) async fn find_available_ips( } transaction.commit().await?; - if split_ips.len() != network.address.len() { - warn!( - "Failed to find available IPs for new device in network {} ({:?})", - network.name, network.address - ); - Ok(ApiResponse { - json: json!({}), - status: StatusCode::NOT_FOUND, - }) - } else { + if split_ips.len() == network.address.len() { debug!( "Found addresses {:?} for new device i network {} ({:?})", split_ips, network.name, network.address @@ -351,6 +360,15 @@ pub(crate) async fn find_available_ips( json: json!(split_ips), status: StatusCode::OK, }) + } else { + warn!( + "Failed to find available IPs for new device in network {} ({:?})", + network.name, network.address + ); + Ok(ApiResponse { + json: json!({}), + status: StatusCode::NOT_FOUND, + }) } } diff --git a/crates/defguard_core/src/handlers/openid_clients.rs b/crates/defguard_core/src/handlers/openid_clients.rs index 0421b60996..cc6a0bd52e 100644 --- a/crates/defguard_core/src/handlers/openid_clients.rs +++ b/crates/defguard_core/src/handlers/openid_clients.rs @@ -89,7 +89,8 @@ pub async fn change_openid_client( "User {} updating OpenID client {client_id}...", session.user.username ); - let status = match OAuth2Client::find_by_client_id(&appstate.pool, &client_id).await? { + let mut transaction = appstate.pool.begin().await?; + let status = match OAuth2Client::find_by_client_id(&mut *transaction, &client_id).await? { Some(mut client) => { // store client before mods let before = client.clone(); @@ -97,7 +98,11 @@ pub async fn change_openid_client( client.redirect_uri = data.redirect_uri; client.enabled = data.enabled; client.scope = data.scope; - client.save(&appstate.pool).await?; + client.save(&mut *transaction).await?; + if before.scope != client.scope { + client.clear_authorizations(&mut *transaction).await?; + } + transaction.commit().await?; info!( "User {} updated OpenID client {client_id} ({})", session.user.username, client.name diff --git a/crates/defguard_core/src/handlers/openid_flow.rs b/crates/defguard_core/src/handlers/openid_flow.rs index eb6d0527f5..dce8b6d569 100644 --- a/crates/defguard_core/src/handlers/openid_flow.rs +++ b/crates/defguard_core/src/handlers/openid_flow.rs @@ -41,7 +41,7 @@ use time::Duration; use super::{ApiResponse, ApiResult, SESSION_COOKIE_NAME}; use crate::{ appstate::AppState, - auth::{AccessUserInfo, SessionInfo}, + auth::{AccessUserInfo, SessionInfo, UserClaims}, db::{ Id, OAuth2AuthorizedApp, OAuth2Token, Session, SessionState, User, models::{auth_code::AuthCode, oauth2client::OAuth2Client}, @@ -52,27 +52,41 @@ use crate::{ }; /// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims -impl From<&User> for StandardClaims { - fn from(user: &User) -> StandardClaims { - let mut name = LocalizedClaim::new(); - name.insert(None, EndUserName::new(user.name())); - let mut given_name = LocalizedClaim::new(); - given_name.insert(None, EndUserGivenName::new(user.first_name.clone())); - let mut given_name = LocalizedClaim::new(); - given_name.insert(None, EndUserGivenName::new(user.first_name.clone())); - let mut family_name = LocalizedClaim::new(); - family_name.insert(None, EndUserFamilyName::new(user.last_name.clone())); - let email = EndUserEmail::new(user.email.clone()); - let phone_number = user.phone.clone().map(EndUserPhoneNumber::new); - let preferred_username = EndUserUsername::new(user.username.clone()); - - StandardClaims::new(SubjectIdentifier::new(user.username.clone())) - .set_name(Some(name)) - .set_given_name(Some(given_name)) - .set_family_name(Some(family_name)) - .set_email(Some(email)) - .set_phone_number(phone_number) - .set_preferred_username(Some(preferred_username)) +impl From<&UserClaims> for StandardClaims { + fn from(user_claims: &UserClaims) -> StandardClaims { + let mut claims = StandardClaims::new(SubjectIdentifier::new(user_claims.sub.clone())); + + if let Some(name) = &user_claims.name { + let mut localized_claim = LocalizedClaim::new(); + localized_claim.insert(None, EndUserName::new(name.clone())); + claims = claims.set_name(Some(localized_claim)); + } + + if let Some(given_name) = &user_claims.given_name { + let mut localized_claim = LocalizedClaim::new(); + localized_claim.insert(None, EndUserGivenName::new(given_name.clone())); + claims = claims.set_given_name(Some(localized_claim)); + } + + if let Some(family_name) = &user_claims.family_name { + let mut localized_claim = LocalizedClaim::new(); + localized_claim.insert(None, EndUserFamilyName::new(family_name.clone())); + claims = claims.set_family_name(Some(localized_claim)); + } + + if let Some(email) = &user_claims.email { + claims = claims.set_email(Some(EndUserEmail::new(email.clone()))); + } + + if let Some(phone_number) = &user_claims.phone_number { + claims = claims.set_phone_number(Some(EndUserPhoneNumber::new(phone_number.clone()))); + } + + if let Some(username) = &user_claims.preferred_username { + claims = claims.set_preferred_username(Some(EndUserUsername::new(username.clone()))); + } + + claims } } @@ -830,10 +844,11 @@ pub async fn token( GroupClaims { groups: None } }; let config = server_config(); + let user_claims = UserClaims::from_user(&user, &client, &token); match form.authorization_code_flow( &auth_code, &token, - (&user).into(), + (&user_claims).into(), &config.url, client.client_secret, config.openid_key(), diff --git a/crates/defguard_core/src/handlers/pagination.rs b/crates/defguard_core/src/handlers/pagination.rs index c0c2bdd86e..6a4327a17c 100644 --- a/crates/defguard_core/src/handlers/pagination.rs +++ b/crates/defguard_core/src/handlers/pagination.rs @@ -1,12 +1,11 @@ use axum::{ body::Body, - http::{HeaderName, HeaderValue}, response::{IntoResponse, Response}, }; use reqwest::StatusCode; use serde::Serialize; -use crate::{VERSION, error::WebError}; +use crate::error::WebError; /// Query params for paginated endpoints #[derive(Debug, Deserialize, Default)] @@ -51,13 +50,6 @@ where } }; - let mut response = Response::new(Body::from(json)); - - response.headers_mut().insert( - HeaderName::from_static("x-defguard-version"), - HeaderValue::from_static(VERSION), - ); - - response + Response::new(Body::from(json)) } } diff --git a/crates/defguard_core/src/handlers/settings.rs b/crates/defguard_core/src/handlers/settings.rs index 77e4d754de..ee7485bca9 100644 --- a/crates/defguard_core/src/handlers/settings.rs +++ b/crates/defguard_core/src/handlers/settings.rs @@ -39,10 +39,7 @@ pub async fn get_settings(_admin: AdminRole, State(appstate): State) - }); } debug!("Retrieved settings"); - Ok(ApiResponse { - json: json!({}), - status: StatusCode::OK, - }) + Ok(ApiResponse::default()) } pub async fn update_settings( @@ -50,7 +47,7 @@ pub async fn update_settings( session: SessionInfo, context: ApiRequestContext, State(appstate): State, - Json(data): Json, + Json(mut data): Json, ) -> ApiResult { debug!("User {} updating settings", session.user.username); @@ -58,6 +55,7 @@ pub async fn update_settings( let before = Settings::get_current_settings(); update_cached_license(data.license.as_deref())?; + data.uuid = before.uuid; data.validate()?; // clone for event let after = data.clone(); diff --git a/crates/defguard_core/src/handlers/updates.rs b/crates/defguard_core/src/handlers/updates.rs index 10e9584ae5..07a5fa43c6 100644 --- a/crates/defguard_core/src/handlers/updates.rs +++ b/crates/defguard_core/src/handlers/updates.rs @@ -1,29 +1,46 @@ -use axum::http::StatusCode; -use serde_json::json; +use axum::{extract::State, http::StatusCode}; +use serde_json::{Value, json}; use super::{ApiResponse, ApiResult}; use crate::{ + appstate::AppState, auth::{AdminRole, SessionInfo}, updates::get_update, + version::IncompatibleComponents, }; -pub async fn check_new_version(_admin: AdminRole, session: SessionInfo) -> ApiResult { +pub(crate) async fn check_new_version(_admin: AdminRole, session: SessionInfo) -> ApiResult { debug!( "User {} is checking if there is a new version available", session.user.username ); - let update = get_update(); - if let Some(update) = update.as_ref() { + let json = if let Some(update) = get_update().as_ref() { debug!("A new version is available, returning the update information"); - Ok(ApiResponse { - json: json!(update), - status: StatusCode::OK, - }) + json!(update) } else { debug!("No new version available"); - Ok(ApiResponse { - json: serde_json::json!({ "message": "No updates available" }), - status: StatusCode::NO_CONTENT, - }) - } + // Front-end expects empty JSON. + Value::Null + }; + Ok(ApiResponse { + json, + status: StatusCode::OK, + }) +} + +// FIXME: Switch to SSE and generally make it better. +pub(crate) async fn outdated_components( + _admin: AdminRole, + State(appstate): State, +) -> ApiResult { + IncompatibleComponents::remove_expired(&appstate.incompatible_components); + let incompatible_components = (*appstate + .incompatible_components + .read() + .expect("Failed to lock appstate.incompatible_components")) + .clone(); + Ok(ApiResponse::new( + json!(incompatible_components), + StatusCode::OK, + )) } diff --git a/crates/defguard_core/src/handlers/user.rs b/crates/defguard_core/src/handlers/user.rs index 2034bdd7ac..909d109439 100644 --- a/crates/defguard_core/src/handlers/user.rs +++ b/crates/defguard_core/src/handlers/user.rs @@ -22,7 +22,7 @@ use crate::{ }, }, enterprise::{ - db::models::enterprise_settings::EnterpriseSettings, + db::models::{api_tokens::ApiToken, enterprise_settings::EnterpriseSettings}, ldap::utils::{ ldap_add_user, ldap_add_user_to_groups, ldap_change_password, ldap_delete_user, ldap_handle_user_modify, ldap_remove_user_from_groups, ldap_update_user_state, @@ -103,10 +103,12 @@ pub(crate) fn check_password_strength(password: &str) -> Result<(), WebError> { /// List of all users /// -/// Retrives list of users. +/// Retrieves list of users. /// /// # Returns -/// Returns list of `UserInfo` objects or `WebError` if error occurs. +/// - List of `UserInfo` objects. +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/user", @@ -114,22 +116,24 @@ pub(crate) fn check_password_strength(password: &str) -> Result<(), WebError> { (status = 200, description = "List of all users.", body = [UserInfo], example = json!( [ { - "authorized_apps": [], - "email": "name@email.com", + "authorized_apps": [], + "email": "mail@mail", "email_mfa_enabled": false, "enrolled": true, "first_name": "first_name", "groups": [ - "group" + "admin" ], "id": 1, "is_active": true, + "is_admin": true, "last_name": "last_name", + "ldap_pass_requires_change": false, "mfa_enabled": false, "mfa_method": "None", "phone": null, "totp_enabled": false, - "username": "username" + "username": "admin" } ])), (status = 401, description = "Unauthorized to list all users.", body = ApiResponse, example = json!({"msg": "Session is required"})), @@ -158,57 +162,39 @@ pub async fn list_users(_role: AdminRole, State(appstate): State) -> A /// Return a user based on provided username parameter. /// /// # Returns -/// Returns `UserDetails` object or `WebError` if error occurs. +/// - `UserDetails` object +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/user/{username}", params( - ("username" = String, description = "name of a user"), + ("username" = String, description = "Name of a user"), ), responses( (status = 200, description = "Return details about user.", body = UserDetails, example = json!( { - "devices": [ - { - "created": "date", - "id": 1, - "name": "name", - "networks": [ - { - "device_wireguard_ip": "1.1.1.1", - "is_active": false, - "last_connected_at": null, - "last_connected_ip": null, - "last_connected_location": null, - "network_gateway_ip": "0.0.0.0", - "network_id": 1, - "network_name": "TestNet" - } - ], - "user_id": 1, - "wireguard_pubkey": "wireguard_pubkey" - } - ], - "security_keys": [], - "user": { - "authorized_apps": [], - "email": "name@email.com", - "email_mfa_enabled": false, - "enrolled": true, - "first_name": "first_name", - "groups": [ - "group" - ], - "id": 1, - "is_active": true, - "last_name": "last_name", - "mfa_enabled": false, - "mfa_method": "None", - "phone": null, - "totp_enabled": false, - "username": "username" - }, - "wallets": [] + "biometric_enabled_devices": [], + "devices": [], + "security_keys": [], + "user": { + "authorized_apps": [], + "email": "mail@defguard.net", + "email_mfa_enabled": false, + "enrolled": true, + "first_name": "first_name", + "groups": [], + "id": 2, + "is_active": true, + "is_admin": false, + "last_name": "last_name", + "ldap_pass_requires_change": false, + "mfa_enabled": false, + "mfa_method": "None", + "phone": "000000000", + "totp_enabled": false, + "username": "username" + } } )), (status = 401, description = "Unauthorized to return details about user.", body = ApiResponse, example = json!({"msg": "Session is required"})), @@ -238,30 +224,32 @@ pub async fn get_user( /// Add a new user based on `AddUserData` object. /// /// # Returns -/// Returns `UserInfo` object or `WebError` if error occurs. +/// - `UserInfo` object +/// +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/user", request_body = AddUserData, responses( (status = 201, description = "Add a new user.", body = UserInfo, example = json!( - { - "authorized_apps": [], - "email": "name@email.com", - "email_mfa_enabled": false, - "enrolled": true, - "first_name": "first_name", - "groups": [ - "admin" - ], - "id": 1, - "is_active": true, - "last_name": "last_name", - "mfa_enabled": false, - "mfa_method": "None", - "phone": null, - "totp_enabled": false, - "username": "username" + { + "authorized_apps": [], + "email": "mail@mail", + "email_mfa_enabled": false, + "enrolled": true, + "first_name": "first_name", + "groups": [], + "id": 3, + "is_active": true, + "is_admin": false, + "last_name": "last_name", + "ldap_pass_requires_change": false, + "mfa_enabled": false, + "mfa_method": "None", + "phone": "000000000", + "totp_enabled": false, + "username": "new_user" } )), (status = 400, description = "Bad request, invalid user data.", body = ApiResponse, example = json!({})), @@ -357,11 +345,16 @@ pub async fn add_user( /// /// Thanks to this endpoint you are able to trigger manually enrollment process, where after finishing you receive an enrollment token. /// -/// `Enrollment token` allows to start the process of gaining access to the company infrastructure `(The enrollment token is valid for 24 hours)`. On the other hand, enrollment url allows the user to access the enrollment form via the web browser or perform the enrollment through the desktop client. +/// **Enrollment token** allows to start the process of gaining access to the company infrastructure **(The enrollment token is valid for 24 hours)**. +/// +/// On the other hand, enrollment url allows the user to access the enrollment form via the web browser or perform the enrollment through the desktop client. /// /// Optionally this endpoint can send an email notification to the user about the enrollment. +/// /// # Returns -/// Returns json with `enrollment token` and `enrollment url` or `WebError` if error occurs. +/// - JSON with `enrollment_token` and `enrollment_url` +/// +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/user/{username}/start_enrollment", @@ -460,11 +453,16 @@ pub async fn start_enrollment( /// /// Thanks to this endpoint you are able to receive a new desktop client configuration or update an existing one. Users need the configuration to connect to the company infrastrcture. /// -/// `Enrollment token` allows to start the process of gaining access to the company infrastructure `(The enrollment token is valid for 24 hours)`. On the other hand, enrollment url allows the user to access the enrollment form via the web browser or perform the enrollment through the desktop client. +/// `Enrollment token` allows to start the process of gaining access to the company infrastructure **(The enrollment token is valid for 24 hours)**. +/// +/// On the other hand, enrollment url allows the user to access the enrollment form via the web browser or perform the enrollment through the desktop client. +/// +/// Optionally this endpoint can send an email notification to the user about the enrollment. /// -/// Optionally this endpoint can send an email notification to the user about the enrollment.``` /// # Returns -/// Returns json with `enrollment token` and `enrollment url` or `WebError` if error occurs. +/// - JSON with `enrollment_token` and `enrollment_url` +/// +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/user/{username}/start_desktop", @@ -565,9 +563,11 @@ pub async fn start_remote_desktop_configuration( /// Username is unique so database returns only single user or nothing. /// /// # Returns -/// Returns only status code 200 if user is available or `WebError` if error occurs. +/// - `200` if the user is available /// -/// `Please take notice that if user exists in database, endpoint will return status code 400.` +/// - `WebError` if error occurs +/// +/// **Please take notice that if user exists in database, endpoint will return status code 400.** #[utoipa::path( post, path = "/api/v1/user/available", @@ -611,16 +611,22 @@ pub async fn username_available( /// Modify user /// -/// Update users data, it can remove authorized apps and active/deactivate ldap status if needed. -/// Endpoint is able to disable a user, but `admin cannot disable himself`. +/// Update user's data basing on `UserInfo` object, it can also remove/add authorized apps and groups assigned to user. +/// +/// Endpoint is able to disable a user, but **admin cannot disable himself**. +/// +/// Disabling a user can be done by setting `is_active` to `false`. +/// /// /// # Returns -/// If erorr occurs, endpoint will return `WebError` object. +/// - empty JSON +/// +/// - `WebError` if error occurs #[utoipa::path( put, path = "/api/v1/user/{username}", params( - ("username" = String, description = "name of a user"), + ("username" = String, description = "Name of a user"), ), request_body = UserInfo, responses( @@ -702,6 +708,15 @@ pub async fn modify_user( user.sync_allowed_devices(&mut transaction, &appstate.wireguard_tx) .await?; } + + // remove API tokens when deactivating a user + if before.is_active && !user.is_active { + let api_tokens = ApiToken::find_by_user_id(&mut *transaction, user.id).await?; + for token in api_tokens { + token.delete(&mut *transaction).await?; + } + } + user_info.into_user_all_fields(&mut user)?; } else { user_info.into_user_safe_fields(&mut user)?; @@ -714,10 +729,10 @@ pub async fn modify_user( ldap_handle_user_modify(&old_username, &mut user, &appstate.pool).await; } - user.maybe_update_rdn().await; + user.maybe_update_rdn(); user.save(&appstate.pool).await?; - ldap_update_user_state(&mut user, &appstate.pool).await; + Box::pin(ldap_update_user_state(&mut user, &appstate.pool)).await; if group_diff.changed() || status_changing { if !group_diff.added.is_empty() { @@ -726,7 +741,7 @@ pub async fn modify_user( group_diff .added .iter() - .map(|g| g.as_str()) + .map(String::as_str) .collect::>(), &appstate.pool, ) @@ -739,7 +754,7 @@ pub async fn modify_user( group_diff .removed .iter() - .map(|g| g.as_str()) + .map(String::as_str) .collect::>(), &appstate.pool, ) @@ -777,15 +792,15 @@ pub async fn modify_user( /// Delete user /// -/// Endpoint helps you delete a user, but `you can't delete yourself as an administrator`. +/// Deletes user, however, **you can't delete yourself as an administrator**. /// /// # Returns -/// If error occurs, endpoint will return `WebError` object. +/// - `WebError` if error occurs #[utoipa::path( delete, path = "/api/v1/user/{username}", params( - ("username" = String, description = "name of a user"), + ("username" = String, description = "Name of a user"), ), responses( (status = 200, description = "User has been deleted."), @@ -854,10 +869,12 @@ pub async fn delete_user( /// Change your own password /// -/// Change your own password, it could return error if password is not strong enough. +/// Changes your own password basing on `PasswordChangeSelf` object. +/// +/// It can return error if password is not strong enough. /// /// # Returns -/// If error occurs, endpoint will return `WebError` object. +/// - `WebError` if error occurs #[utoipa::path( put, path = "/api/v1/user/change_password", @@ -915,21 +932,23 @@ pub async fn change_self_password( /// Change user password /// -/// Change user password, it could return error if password is not strong enough. +/// Change user password basing on `PasswordChange` object, it can return error if password is not strong enough. /// -/// `This endpoint doesn't allow you to change your own password. Go to: /api/v1/user/change_password.` +/// This endpoint doesn't allow you to **change your own** password. +/// +/// If you want to change your own password please go to: `/api/v1/user/change_password`. /// /// # Returns -/// If error occurs, endpoint will return `WebError` object. +/// - `WebError` if error occurs #[utoipa::path( put, path = "/api/v1/user/{username}/password", params( - ("username" = String, description = "name of a user"), + ("username" = String, description = "Name of a user"), ), request_body = PasswordChange, responses( - (status = 200, description = "Pasword has been changed.", body = ApiResponse, example = json!({})), + (status = 200, description = "Password has been changed.", body = ApiResponse, example = json!({})), (status = 400, description = "Bad request, password does not satisfy requirements. This endpoint does not change your own password.", body = ApiResponse, example = json!({})), (status = 401, description = "Unauthorized to change password.", body = ApiResponse, example = json!({"msg": "Session is required"})), (status = 403, description = "You don't have permission to change user password.", body = ApiResponse, example = json!({"msg": "access denied"})), @@ -1003,17 +1022,17 @@ pub async fn change_password( /// Reset user password /// -/// Reset user password, it will send a new enrollment to the user's email. +/// Reset user password, it will send a new enrollment token to the user's email. /// -/// `This endpoint doesn't allow you to reset your own password.` +/// **This endpoint doesn't allow you to reset your own password.** /// /// # Returns -/// If error occurs, endpoint will return `WebError` object. +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/user/{username}/reset_password", params( - ("username" = String, description = "name of a user"), + ("username" = String, description = "Name of a user"), ), responses( (status = 200, description = "Successfully reset user password."), @@ -1117,16 +1136,16 @@ pub async fn reset_password( /// Delete security key /// -/// Delete Webauthn security key that allows users to authenticate. +/// Delete WebAuthn security key that allows users to authenticate. /// /// # Returns -/// Returns `WebError` object if error occurs. +/// - `WebError` if error occurs #[utoipa::path( delete, path = "/api/v1/user/{username}/security_key/{id}", params( - ("username" = String, description = "name of a user"), - ("id" = i64, description = "id of security key that could point to passkey") + ("username" = String, description = "Name of a user"), + ("id" = i64, description = "ID of security key that could point to passkey") ), responses( (status = 200, description = "Successfully deleted security key."), @@ -1182,36 +1201,40 @@ pub async fn delete_security_key( /// Returns your data /// -/// Endpoint returns the data associated with the current session user``` +/// Endpoint returns the data associated with the current session user /// /// # Returns -/// Returns `UserInfo` object or `WebError` object if error occurs. +/// - `UserInfo` object +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/me", responses( (status = 200, description = "Returns your own data.", body = UserInfo, example = json!( { - "authorized_apps": [], - "email": "name@email.com", - "email_mfa_enabled": false, - "enrolled": true, - "first_name": "first_name", - "groups": [ - "group" - ], - "id": 1, - "is_active": true, - "last_name": "last_name", - "mfa_enabled": false, - "mfa_method": "None", - "phone": null, - "totp_enabled": false, - "username": "username" - } + "authorized_apps": [], + "email": "mail@mail", + "email_mfa_enabled": false, + "enrolled": true, + "first_name": "first_name", + "groups": [ + "admin" + ], + "id": 1, + "is_active": true, + "is_admin": true, + "last_name": "last_name", + "ldap_pass_requires_change": false, + "mfa_enabled": false, + "mfa_method": "None", + "phone": 000_000_000, + "totp_enabled": false, + "username": "username" + } )), (status = 401, description = "Unauthorized return own user data.", body = ApiResponse, example = json!({"msg": "Session is required"})), - (status = 500, description = "Cannot retrive own user data.", body = ApiResponse, example = json!({"msg": "Internal server error"})) + (status = 500, description = "Cannot retrieve own user data.", body = ApiResponse, example = json!({"msg": "Internal server error"})) ), security( ("cookie" = []), @@ -1226,17 +1249,17 @@ pub async fn me(session: SessionInfo, State(appstate): State) -> ApiRe }) } -/// Delete Oauth token. +/// Delete OAuth token. /// -/// Endpoint helps your to delete authorized application by `OAuth2` id. +/// Deletes an authorized application by `OAuth2` ID. /// /// # Returns -/// Returns `WebError` object if error occurs. +/// - `WebError` if error occurs #[utoipa::path( delete, path = "/api/v1/user/{username}/oauth_app/{oauth2client_id}", params( - ("username" = String, description = "name of a user"), + ("username" = String, description = "Name of a user"), ("oauth2client_id" = i64, description = "id of OAuth2 client") ), responses( diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index ab4f3d9852..1508bc7cdc 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -21,7 +21,7 @@ use super::{ApiResponse, ApiResult, WebError, device_for_admin_or_self, user_for use crate::{ AsCsv, appstate::AppState, - auth::{AdminRole, Claims, ClaimsType, SessionInfo}, + auth::{AdminRole, SessionInfo}, db::{ AddDevice, Device, GatewayEvent, Id, WireguardNetwork, models::{ @@ -35,9 +35,14 @@ use crate::{ }, }, }, - enterprise::{handlers::CanManageDevices, limits::update_counts}, + enterprise::{ + db::models::{enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider}, + handlers::CanManageDevices, + is_enterprise_enabled, + limits::update_counts, + }, events::{ApiEvent, ApiEventType, ApiRequestContext}, - grpc::GatewayMap, + grpc::gateway::map::GatewayMap, handlers::mail::send_new_device_added_email, server_config, templates::TemplateLocation, @@ -88,6 +93,36 @@ impl WireguardNetworkData { .as_ref() .map_or(Vec::new(), |ips| parse_network_address_list(ips)) } + + pub(crate) async fn validate_location_mfa_mode<'e, E: sqlx::PgExecutor<'e>>( + &self, + executor: E, + ) -> Result<(), WebError> { + // if external MFA was chosen verify if enterprise features are enabled + // and external OpenID provider is configured + if self.location_mfa_mode == LocationMfaMode::External { + if !is_enterprise_enabled() { + error!( + "Unable to create location with external MFA. External OpenID provider is not configured" + ); + + return Err(WebError::Forbidden( + "Cannot enable external MFA. Enterprise features are disabled".into(), + )); + } + + if OpenIdProvider::get_current(executor).await?.is_none() { + error!( + "Unable to create location with external MFA. External OpenID provider is not configured" + ); + return Err(WebError::BadRequest( + "Cannot enable external MFA. External OpenID provider is not configured".into(), + )); + } + } + + Ok(()) + } } // Used in process of importing network from WireGuard config @@ -110,6 +145,14 @@ pub struct ImportedNetworkData { pub devices: Vec, } +/// Create new network +/// +/// Create new network based on `WireguardNetworkData` object. +/// +/// # Returns +/// - `WireguardNetwork` object +/// +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/network", @@ -137,6 +180,9 @@ pub(crate) async fn create_network( "User {} creating WireGuard network {network_name}", session.user.username ); + + data.validate_location_mfa_mode(&appstate.pool).await?; + let allowed_ips = data.parse_allowed_ips(); let network = WireguardNetwork::new( data.name, @@ -150,8 +196,7 @@ pub(crate) async fn create_network( data.acl_enabled, data.acl_default_allow, data.location_mfa_mode, - ) - .map_err(|_| WebError::Serialization("Invalid network address".into()))?; + ); let mut transaction = appstate.pool.begin().await?; let network = network.save(&mut *transaction).await?; @@ -192,6 +237,14 @@ async fn find_network(id: Id, pool: &PgPool) -> Result, Web .ok_or_else(|| WebError::ObjectNotFound(format!("Network {id} not found"))) } +/// Modify network +/// +/// Modify existing network basing on `WireguardNetworkData` object. +/// +/// # Returns +/// - `WireguardNetwork` object +/// +/// - `WebError` if error occurs #[utoipa::path( put, path = "/api/v1/network/{network_id}", @@ -220,6 +273,8 @@ pub(crate) async fn modify_network( "User {} updating WireGuard network {network_id}", session.user.username ); + data.validate_location_mfa_mode(&appstate.pool).await?; + let mut network = find_network(network_id, &appstate.pool).await?; // store network before mods let before = network.clone(); @@ -274,6 +329,12 @@ pub(crate) async fn modify_network( }) } +/// Delete network +/// +/// # Returns +/// - empty JSON +/// +/// - `WebError` if error occurs #[utoipa::path( delete, path = "/api/v1/network/{network_id}", @@ -325,6 +386,14 @@ pub(crate) async fn delete_network( Ok(ApiResponse::default()) } +/// List of all networks +/// +/// Retrieve list of all networks +/// +/// # Returns +/// - List of `WireguardNetworkInfo` objects +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/network", @@ -371,6 +440,14 @@ pub(crate) async fn list_networks( }) } +/// Details of network +/// +/// Retrieve details about network with `network_id`. +/// +/// # Returns +/// - `WireguardNetworkInfo` object +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/network/{network_id}", @@ -444,19 +521,16 @@ pub(crate) async fn gateway_status( /// Returns state of gateways for all networks /// -/// Returns current state of gateways as `HashMap>` where key is an id of `WireguardNetwork` +/// Returns current state of gateways as `HashMap>` where key is an id of `WireguardNetwork` pub(crate) async fn all_gateways_status( _role: AdminRole, Extension(gateway_state): Extension>>, ) -> ApiResult { debug!("Displaying gateways status for all networks."); - let gateway_state = { - let lock = gateway_state - .lock() - .expect("Failed to acquire gateway state lock"); - lock.clone() - }; - let flattened = gateway_state.into_flattened(); + let gateway_state = gateway_state + .lock() + .expect("Failed to acquire gateway state lock"); + let flattened = (*gateway_state).as_flattened(); Ok(ApiResponse { json: json!(flattened), status: StatusCode::OK, @@ -608,7 +682,9 @@ pub struct AddDeviceResult { /// Add device /// /// Add a new device for a user by sending `AddDevice` object. +/// /// Notice that `wireguard_pubkey` must be unique to successfully add the device. +/// /// You can't add devices for `disabled` users, unless you are an admin. /// /// Device will be added to all networks in your company infrastructure. @@ -616,12 +692,14 @@ pub struct AddDeviceResult { /// User will receive all new device details on email. /// /// # Returns -/// Returns `AddDeviceResult` object or `WebError` object if error occurs. +/// - `AddDeviceResult` object +/// +/// - `WebError` if error occurs #[utoipa::path( post, path = "/api/v1/device/{device_id}", params( - ("device_id" = String, description = "Name of a user.") + ("device_id" = String, description = "ID of device.") ), request_body = AddDevice, responses( @@ -677,9 +755,20 @@ pub(crate) async fn add_device( let user = user_for_admin_or_self(&appstate.pool, &session, &username).await?; + let settings = EnterpriseSettings::get(&appstate.pool).await?; + if settings.only_client_activation && !session.is_admin { + warn!( + "User {} tried to add a device, but manual device management is disaled", + session.user.username + ); + return Err(WebError::Forbidden( + "Manual device management is disabled".into(), + )); + } + // Let admins manage devices for disabled users if !user.is_active && !session.is_admin { - info!( + warn!( "User {} tried to add a device for a disabled user {username}", session.user.username ); @@ -818,17 +907,20 @@ pub(crate) async fn add_device( /// Modify device /// /// Update a device for a user by sending `ModifyDevice` object. -/// Notice that `wireguard_pubkey` must be diffrent from server's pubkey. +/// +/// Notice that `wireguard_pubkey` must be different from server's pubkey. /// /// Endpoint will trigger new update in gateway server. /// /// # Returns -/// Returns `Device` object or `WebError` object if error occurs. +/// - `Device` object +/// +/// - `WebError` if error occurs #[utoipa::path( put, path = "/api/v1/device/{device_id}", params( - ("device_id" = i64, description = "Id of device to update details.") + ("device_id" = i64, description = "ID of device.") ), request_body = ModifyDevice, responses( @@ -860,6 +952,18 @@ pub(crate) async fn modify_device( Json(data): Json, ) -> ApiResult { debug!("User {} updating device {device_id}", session.user.username); + + let settings = EnterpriseSettings::get(&appstate.pool).await?; + if settings.only_client_activation && !session.is_admin { + warn!( + "User {} tried to add a device, but manual device management is disaled", + session.user.username + ); + return Err(WebError::Forbidden( + "Manual device management is disabled".into(), + )); + } + let mut device = device_for_admin_or_self(&appstate.pool, &session, device_id).await?; // store device before mods let before = device.clone(); @@ -933,13 +1037,17 @@ pub(crate) async fn modify_device( /// Get device /// +/// Retrieve information about device based on their `device_id` +/// /// # Returns -/// Returns `Device` object or `WebError` object if error occurs. +/// - `Device` object +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/device/{device_id}", params( - ("device_id" = i64, description = "Id of device to update details.") + ("device_id" = i64, description = "ID of device to update details.") ), responses( (status = 200, description = "Successfully updated a device.", body = Device, example = json!( @@ -979,12 +1087,14 @@ pub(crate) async fn get_device( /// Delete user device and trigger new update in gateway server. /// /// # Returns -/// If error occurs it returns `WebError` object. +/// - empty JSON +/// +/// - `WebError` if error occurs #[utoipa::path( delete, path = "/api/v1/device/{device_id}", params( - ("device_id" = i64, description = "Id of device to update details.") + ("device_id" = i64, description = "ID of device to update details.") ), responses( (status = 200, description = "Successfully deleted device."), @@ -1054,7 +1164,7 @@ pub(crate) async fn delete_device( appstate.emit_event(ApiEvent { context, event: Box::new(ApiEventType::UserDeviceRemoved { device, owner }), - })? + })?; } DeviceType::Network => { if let Some(network_info) = device_info.network_info.first() { @@ -1079,7 +1189,7 @@ pub(crate) async fn delete_device( ); } } - }; + } transaction.commit().await?; info!("User {username} deleted device {device_id}"); @@ -1088,8 +1198,12 @@ pub(crate) async fn delete_device( /// List all devices /// +/// Retrieves all devices +/// /// # Returns -/// Returns a list `Device` objects or `WebError` object if error occurs. +/// - List of `Device` objects +/// +/// - `WebError` if error occurs #[utoipa::path( get, path = "/api/v1/device", @@ -1124,10 +1238,14 @@ pub(crate) async fn list_devices(_role: AdminRole, State(appstate): State, ) -> Result { debug!("Creating config for device {device_id} in network {network_id}"); + + let settings = EnterpriseSettings::get(&appstate.pool).await?; + if settings.only_client_activation && !session.is_admin { + warn!( + "User {} tried to download device config, but manual device management is disaled", + session.user.username + ); + return Err(WebError::Forbidden( + "Manual device management is disabled".into(), + )); + } + let network = find_network(network_id, &appstate.pool).await?; let device = device_for_admin_or_self(&appstate.pool, &session, device_id).await?; let wireguard_network_device = @@ -1207,14 +1337,7 @@ pub(crate) async fn create_network_token( ) -> ApiResult { debug!("Generating a new token for network ID {network_id}"); let network = find_network(network_id, &appstate.pool).await?; - let token = Claims::new( - ClaimsType::Gateway, - format!("DEFGUARD-NETWORK-{network_id}"), - network_id.to_string(), - u32::MAX.into(), - ) - .to_jwt() - .map_err(|_| { + let token = network.generate_gateway_token().map_err(|_| { error!("Failed to create token for gateway {}", network.name); WebError::Authorization(format!( "Failed to create token for gateway {}", diff --git a/crates/defguard_core/src/headers.rs b/crates/defguard_core/src/headers.rs index 41b594d675..c9b1bd796d 100644 --- a/crates/defguard_core/src/headers.rs +++ b/crates/defguard_core/src/headers.rs @@ -1,5 +1,6 @@ use std::{borrow::Borrow, sync::LazyLock}; +use axum::http::{HeaderName, HeaderValue}; use sqlx::PgPool; use tokio::sync::mpsc::UnboundedSender; use uaparser::{Client, Parser, UserAgentParser}; @@ -11,6 +12,11 @@ use crate::{ templates::TemplateError, }; +pub(crate) const CONTENT_SECURITY_POLICY_HEADER_NAME: HeaderName = + HeaderName::from_static("content-security-policy"); +pub(crate) const CONTENT_SECURITY_POLICY_HEADER_VALUE: HeaderValue = + HeaderValue::from_static("frame-ancestors 'none';"); + pub(crate) static USER_AGENT_PARSER: LazyLock = LazyLock::new(|| { let regexes = include_bytes!("../user_agent_header_regexes.yaml"); UserAgentParser::from_bytes(regexes).expect("Parser creation failed") diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 1f8d578d1e..68f82136ff 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -3,17 +3,19 @@ #![allow(clippy::result_large_err)] use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, OnceLock, RwLock}, }; +use crate::version::IncompatibleComponents; use anyhow::anyhow; use axum::{ Extension, Json, Router, http::{Request, StatusCode}, - routing::{delete, get, patch, post, put}, + routing::{delete, get, post, put}, serve, }; use db::models::{device::DeviceType, wireguard::LocationMfaMode}; +use defguard_version::server::DefguardVersionLayer; use defguard_web_ui::{index, svg, web_asset}; use enterprise::{ handlers::{ @@ -59,16 +61,19 @@ use handlers::{ }; use ipnetwork::IpNetwork; use secrecy::ExposeSecret; +use semver::Version; use sqlx::PgPool; use tokio::{ net::TcpListener, sync::{ - OnceCell, broadcast::Sender, mpsc::{UnboundedReceiver, UnboundedSender}, }, }; -use tower_http::trace::{DefaultOnResponse, TraceLayer}; +use tower_http::{ + set_header::SetResponseHeaderLayer, + trace::{DefaultOnResponse, TraceLayer}, +}; use tracing::Level; use utoipa::{ Modify, OpenApi, @@ -124,6 +129,7 @@ use self::{ }, ssh_authorized_keys::get_authorized_keys, support::{configuration, logs}, + updates::outdated_components, user::{ add_user, change_password, change_self_password, delete_authorized_app, delete_security_key, delete_user, get_user, list_users, me, modify_user, @@ -140,7 +146,7 @@ use self::{ use self::{ auth::failed_login::FailedLoginMap, db::models::oauth2client::OAuth2Client, - grpc::{GatewayMap, WorkerState}, + grpc::{WorkerState, gateway::map::GatewayMap}, handlers::app_info::get_app_info, }; @@ -163,6 +169,7 @@ pub mod support; pub mod templates; pub mod updates; pub mod utility_thread; +pub mod version; pub mod wg_config; pub mod wireguard_peer_disconnect; pub mod wireguard_stats_purge; @@ -177,8 +184,8 @@ extern crate serde; // reference: https://docs.rs/sqlx/latest/sqlx/attr.test.html#automatic-migrations-requires-migrate-feature pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); -pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_SHA")); -pub static SERVER_CONFIG: OnceCell = OnceCell::const_new(); +pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "+", env!("VERGEN_GIT_SHA")); +pub static SERVER_CONFIG: OnceLock = OnceLock::new(); pub(crate) fn server_config() -> &'static DefGuardConfig { SERVER_CONFIG @@ -190,7 +197,6 @@ pub(crate) fn server_config() -> &'static DefGuardConfig { pub(crate) const KEY_LENGTH: usize = 32; mod openapi { - use crate::enterprise::snat::handlers as snat; use db::{ AddDevice, UserDetails, UserInfo, models::device::{ModifyDevice, UserDevice}, @@ -208,6 +214,7 @@ mod openapi { }; use super::*; + use crate::{enterprise::snat::handlers as snat, error::WebError}; #[derive(OpenApi)] #[openapi( @@ -256,47 +263,55 @@ mod openapi { snat::create_snat_binding, snat::modify_snat_binding, snat::delete_snat_binding, - ), components( schemas( - ApiResponse, UserInfo, UserDetails, UserDevice, Groups, Username, StartEnrollmentRequest, PasswordChangeSelf, PasswordChange, AddDevice, AddDeviceResult, Device, ModifyDevice, BulkAssignToGroupsRequest, GroupInfo, EditGroupInfo + ApiResponse, UserInfo, UserDetails, UserDevice, Groups, Username, StartEnrollmentRequest, PasswordChangeSelf, PasswordChange, AddDevice, AddDeviceResult, Device, ModifyDevice, BulkAssignToGroupsRequest, GroupInfo, EditGroupInfo, WebError ), ), tags( (name = "user", description = " -Endpoints that allow to control user data. - +### Endpoints for managing users Available actions: - list all users +- disable/enable user - CRUD mechanism for handling users - operations on security key and authorized app - change user password. +- start remote desktop configuratiion +- trigger enrollment process "), (name = "group", description = " -Endpoints that allow to control groups in your network. - +### Endpoints for managing groups Available actions: - list all groups - CRUD mechanism for handling groups -- add or delete a group member. +- add or delete a group member +- remove group +- bulk assign users to groups "), (name = "device", description = " -Endpoints that allow to control devices in your network. +### Endpoints for managing devices Available actions: - list all devices or user devices - CRUD mechanism for handling devices. "), (name = "network", description = " -Endpoints that allow to control your networks. +### Endpoints that allow to control your networks. Available actions: - list all wireguard networks - CRUD mechanism for handling devices. "), (name = "SNAT", description = " -Endpoints that allow you to control user SNAT bindings for your locations. +### Endpoints that allow you to control user SNAT bindings for your locations. + +Available actions: +- list all SNAT bindings +- create new SNAT binding +- modify SNAT binding +- delete SNAT binding "), ) )] @@ -345,6 +360,8 @@ pub fn build_webapp( pool: PgPool, failed_logins: Arc>, event_tx: UnboundedSender, + version: Version, + incompatible_components: Arc>, ) -> Router { let webapp: Router = Router::new() .route("/", get(index)) @@ -365,41 +382,42 @@ pub fn build_webapp( // /auth .route("/auth", post(authenticate)) .route("/auth/logout", post(logout)) - .route("/auth/mfa", put(mfa_enable)) - .route("/auth/mfa", delete(mfa_disable)) + .route("/auth/mfa", put(mfa_enable).delete(mfa_disable)) .route("/auth/webauthn/init", post(webauthn_init)) .route("/auth/webauthn/finish", post(webauthn_finish)) .route("/auth/webauthn/start", post(webauthn_start)) .route("/auth/webauthn", post(webauthn_end)) .route("/auth/totp/init", post(totp_secret)) - .route("/auth/totp", post(totp_enable)) - .route("/auth/totp", delete(totp_disable)) + .route("/auth/totp", post(totp_enable).delete(totp_disable)) .route("/auth/totp/verify", post(totp_code)) .route("/auth/email/init", post(email_mfa_init)) - .route("/auth/email", get(request_email_mfa_code)) - .route("/auth/email", post(email_mfa_enable)) - .route("/auth/email", delete(email_mfa_disable)) + .route( + "/auth/email", + get(request_email_mfa_code) + .post(email_mfa_enable) + .delete(email_mfa_disable), + ) .route("/auth/email/verify", post(email_mfa_code)) .route("/auth/recovery", post(recovery_code)) // /user - .route("/user", get(list_users)) + .route("/user", get(list_users).post(add_user)) .route("/user/{username}", get(get_user)) - .route("/user", post(add_user)) .route("/user/{username}/start_enrollment", post(start_enrollment)) .route( "/user/{username}/start_desktop", post(start_remote_desktop_configuration), ) .route("/user/available", post(username_available)) - .route("/user/{username}", put(modify_user)) - .route("/user/{username}", delete(delete_user)) + .route("/user/{username}", put(modify_user).delete(delete_user)) // FIXME: username `change_password` is invalid .route("/user/change_password", put(change_self_password)) .route("/user/{username}/password", put(change_password)) .route("/user/{username}/reset_password", post(reset_password)) // auth keys - .route("/user/{username}/auth_key", get(fetch_authentication_keys)) - .route("/user/{username}/auth_key", post(add_authentication_key)) + .route( + "/user/{username}/auth_key", + get(fetch_authentication_keys).post(add_authentication_key), + ) .route( "/user/{username}/auth_key/{key_id}", delete(delete_authentication_key), @@ -415,8 +433,10 @@ pub fn build_webapp( post(rename_yubikey), ) // API tokens - .route("/user/{username}/api_token", get(fetch_api_tokens)) - .route("/user/{username}/api_token", post(add_api_token)) + .route( + "/user/{username}/api_token", + get(fetch_api_tokens).post(add_api_token), + ) .route( "/user/{username}/api_token/{token_id}", delete(delete_api_token), @@ -438,12 +458,14 @@ pub fn build_webapp( // forward_auth .route("/forward_auth", get(forward_auth)) // group - .route("/group", get(list_groups)) - .route("/group", post(create_group)) - .route("/group/{name}", get(get_group)) - .route("/group/{name}", put(modify_group)) - .route("/group/{name}", delete(delete_group)) - .route("/group/{name}", post(add_group_member)) + .route("/group", get(list_groups).post(create_group)) + .route( + "/group/{name}", + get(get_group) + .put(modify_group) + .delete(delete_group) + .post(add_group_member), + ) .route("/group/{name}/user/{username}", delete(remove_group_member)) .route("/group-info", get(list_groups_info)) .route("/groups-assign", post(bulk_assign_to_groups)) @@ -451,25 +473,30 @@ pub fn build_webapp( .route("/mail/test", post(test_mail)) .route("/mail/support", post(send_support_data)) // settings - .route("/settings", get(get_settings)) - .route("/settings", put(update_settings)) - .route("/settings", patch(patch_settings)) + .route( + "/settings", + get(get_settings).put(update_settings).patch(patch_settings), + ) .route("/settings/{id}", put(set_default_branding)) // settings for frontend .route("/settings_essentials", get(get_settings_essentials)) // enterprise settings - .route("/settings_enterprise", get(get_enterprise_settings)) - .route("/settings_enterprise", patch(patch_enterprise_settings)) + .route( + "/settings_enterprise", + get(get_enterprise_settings).patch(patch_enterprise_settings), + ) // support .route("/support/configuration", get(configuration)) .route("/support/logs", get(logs)) // webhooks - .route("/webhook", post(add_webhook)) - .route("/webhook", get(list_webhooks)) - .route("/webhook/{id}", get(get_webhook)) - .route("/webhook/{id}", put(change_webhook)) - .route("/webhook/{id}", delete(delete_webhook)) - .route("/webhook/{id}", post(change_enabled)) + .route("/webhook", post(add_webhook).get(list_webhooks)) + .route( + "/webhook/{id}", + get(get_webhook) + .put(change_webhook) + .delete(delete_webhook) + .post(change_enabled), + ) // ldap .route("/ldap/test", get(test_ldap_settings)) // activity log @@ -480,8 +507,10 @@ pub fn build_webapp( let webapp = webapp.nest( "/api/v1/openid", Router::new() - .route("/provider", get(get_current_openid_provider)) - .route("/provider", post(add_openid_provider)) + .route( + "/provider", + get(get_current_openid_provider).post(add_openid_provider), + ) .route("/provider/{name}", delete(delete_openid_provider)) .route("/callback", post(auth_callback)) .route("/auth_info", get(get_auth_info)), @@ -498,10 +527,14 @@ pub fn build_webapp( let webapp = webapp.nest( "/api/v1/activity_log_stream", Router::new() - .route("/", get(get_activity_log_stream)) - .route("/", post(create_activity_log_stream)) - .route("/{id}", delete(delete_activity_log_stream)) - .route("/{id}", put(modify_activity_log_stream)), + .route( + "/", + get(get_activity_log_stream).post(create_activity_log_stream), + ) + .route( + "/{id}", + delete(delete_activity_log_stream).put(modify_activity_log_stream), + ), ); #[cfg(feature = "openid")] @@ -510,14 +543,15 @@ pub fn build_webapp( "/api/v1/oauth", Router::new() .route("/discovery/keys", get(discovery_keys)) - .route("/", post(add_openid_client)) - .route("/", get(list_openid_clients)) - .route("/{client_id}", get(get_openid_client)) - .route("/{client_id}", put(change_openid_client)) - .route("/{client_id}", post(change_openid_client_state)) - .route("/{client_id}", delete(delete_openid_client)) - .route("/authorize", get(authorization)) - .route("/authorize", post(secure_authorization)) + .route("/", post(add_openid_client).get(list_openid_clients)) + .route( + "/{client_id}", + get(get_openid_client) + .put(change_openid_client) + .post(change_openid_client_state) + .delete(delete_openid_client), + ) + .route("/authorize", get(authorization).post(secure_authorization)) .route("/token", post(token)) .route("/userinfo", get(userinfo)), ) @@ -529,17 +563,21 @@ pub fn build_webapp( let webapp = webapp.nest( "/api/v1/acl", Router::new() - .route("/rule", get(list_acl_rules)) - .route("/rule", post(create_acl_rule)) + .route("/rule", get(list_acl_rules).post(create_acl_rule)) .route("/rule/apply", put(apply_acl_rules)) - .route("/rule/{id}", get(get_acl_rule)) - .route("/rule/{id}", put(update_acl_rule)) - .route("/rule/{id}", delete(delete_acl_rule)) - .route("/alias", get(list_acl_aliases)) - .route("/alias", post(create_acl_alias)) - .route("/alias/{id}", get(get_acl_alias)) - .route("/alias/{id}", put(update_acl_alias)) - .route("/alias/{id}", delete(delete_acl_alias)) + .route( + "/rule/{id}", + get(get_acl_rule) + .put(update_acl_rule) + .delete(delete_acl_rule), + ) + .route("/alias", get(list_acl_aliases).post(create_acl_alias)) + .route( + "/alias/{id}", + get(get_acl_alias) + .put(update_acl_alias) + .delete(delete_acl_alias), + ) .route("/alias/apply", put(apply_acl_aliases)), ); @@ -549,22 +587,27 @@ pub fn build_webapp( Router::new() // FIXME: Conflict; change /device/{device_id} to /device/{username}. .route("/device/{device_id}", post(add_device)) - .route("/device/{device_id}", put(modify_device)) - .route("/device/{device_id}", get(get_device)) - .route("/device/{device_id}", delete(delete_device)) + .route( + "/device/{device_id}", + put(modify_device).get(get_device).delete(delete_device), + ) .route("/device", get(list_devices)) .route("/device/user/{username}", get(list_user_devices)) // Network devices, as opposed to user devices - .route("/device/network", post(add_network_device)) - .route("/device/network", get(list_network_devices)) - .route("/device/network/ip/{network_id}", get(find_available_ips)) + .route( + "/device/network", + post(add_network_device).get(list_network_devices), + ) .route( "/device/network/ip/{network_id}", - post(check_ip_availability), + get(find_available_ips).post(check_ip_availability), + ) + .route( + "/device/network/{device_id}", + put(modify_network_device) + .get(get_network_device) + .delete(delete_device), ) - .route("/device/network/{device_id}", put(modify_network_device)) - .route("/device/network/{device_id}", get(get_network_device)) - .route("/device/network/{device_id}", delete(delete_device)) .route( "/device/network/{device_id}/config", get(download_network_device_config), @@ -577,14 +620,16 @@ pub fn build_webapp( "/device/network/start_cli/{device_id}", post(start_network_device_setup_for_device), ) - .route("/network", post(create_network)) - .route("/network", get(list_networks)) + .route("/network", post(create_network).get(list_networks)) .route("/network/import", post(import_network)) .route("/network/stats", get(networks_overview_stats)) .route("/network/gateways", get(all_gateways_status)) - .route("/network/{network_id}", put(modify_network)) - .route("/network/{network_id}", delete(delete_network)) - .route("/network/{network_id}", get(network_details)) + .route( + "/network/{network_id}", + put(modify_network) + .delete(delete_network) + .get(network_details), + ) .route("/network/{network_id}/gateways", get(gateway_status)) .route( "/network/{network_id}/gateways/{gateway_id}", @@ -598,16 +643,15 @@ pub fn build_webapp( .route("/network/{network_id}/token", get(create_network_token)) .route("/network/{network_id}/stats/users", get(devices_stats)) .route("/network/{network_id}/stats", get(network_stats)) - .route("/network/{location_id}/snat", get(list_snat_bindings)) - .route("/network/{location_id}/snat", post(create_snat_binding)) .route( - "/network/{location_id}/snat/{user_id}", - put(modify_snat_binding), + "/network/{location_id}/snat", + get(list_snat_bindings).post(create_snat_binding), ) .route( "/network/{location_id}/snat/{user_id}", - delete(delete_snat_binding), + put(modify_snat_binding).delete(delete_snat_binding), ) + .route("/outdated", get(outdated_components)) .layer(Extension(gateway_state)), ); @@ -618,11 +662,17 @@ pub fn build_webapp( .route("/job", post(create_job)) .route("/token", get(create_worker_token)) .route("/", get(list_workers)) - .route("/{id}", delete(remove_worker)) - .route("/{id}", get(job_status)) + .route("/{id}", delete(remove_worker).get(job_status)) .layer(Extension(worker_state)), ); + let webapp = webapp.layer(DefguardVersionLayer::new(version)).layer( + SetResponseHeaderLayer::if_not_present( + headers::CONTENT_SECURITY_POLICY_HEADER_NAME, + headers::CONTENT_SECURITY_POLICY_HEADER_VALUE, + ), + ); + let swagger = SwaggerUi::new("/api-docs").url("/api-docs/openapi.json", openapi::ApiDoc::openapi()); @@ -635,6 +685,7 @@ pub fn build_webapp( mail_tx, failed_logins, event_tx, + incompatible_components, )) .layer( TraceLayer::new_for_http() @@ -662,6 +713,7 @@ pub async fn run_web_server( pool: PgPool, failed_logins: Arc>, event_tx: UnboundedSender, + incompatible_components: Arc>, ) -> Result<(), anyhow::Error> { let webapp = build_webapp( webhook_tx, @@ -673,6 +725,8 @@ pub async fn run_web_server( pool, failed_logins, event_tx, + Version::parse(VERSION)?, + incompatible_components, ); info!("Started web services"); let addr = SocketAddr::new( @@ -739,8 +793,7 @@ pub async fn init_dev_env(config: &DefGuardConfig) { false, false, LocationMfaMode::Disabled, - ) - .expect("Could not create network"); + ); network.pubkey = "zGMeVGm9HV9I4wSKF9AXmYnnAIhDySyqLMuKpcfIaQo=".to_string(); network.prvkey = "MAk3d5KuB167G88HM7nGYR6ksnPMAOguAg2s5EcPp1M=".to_string(); network @@ -814,12 +867,12 @@ pub async fn init_vpn_location( let network = if let Some(mut network) = WireguardNetwork::find_by_id(&mut *transaction, location_id).await? { - network.name = args.name.clone(); + network.name.clone_from(&args.name); network.address = vec![args.address]; network.port = args.port; - network.endpoint = args.endpoint.clone(); - network.dns = args.dns.clone(); - network.allowed_ips = args.allowed_ips.clone(); + network.endpoint.clone_from(&args.endpoint); + network.dns.clone_from(&args.dns); + network.allowed_ips.clone_from(&args.allowed_ips); network.save(&mut *transaction).await?; network.sync_allowed_devices(&mut transaction, None).await?; network @@ -838,7 +891,7 @@ pub async fn init_vpn_location( false, false, LocationMfaMode::Disabled, - )? + ) .save(&mut *transaction) .await?; if network.id != location_id { @@ -862,7 +915,7 @@ pub async fn init_vpn_location( return Err(anyhow!( "Failed to initialize first VPN location. Location already exists." )); - }; + } // create a new network WireguardNetwork::new( @@ -877,7 +930,7 @@ pub async fn init_vpn_location( false, false, LocationMfaMode::Disabled, - )? + ) .save(pool) .await? }; diff --git a/crates/defguard_core/src/templates.rs b/crates/defguard_core/src/templates.rs index 79bae71018..0c945ffe47 100644 --- a/crates/defguard_core/src/templates.rs +++ b/crates/defguard_core/src/templates.rs @@ -1,6 +1,9 @@ +use std::collections::HashMap; + use chrono::{Datelike, NaiveDateTime, Utc}; use reqwest::Url; -use tera::{Context, Tera}; +use serde_json::Value; +use tera::{Context, Function, Tera}; use thiserror::Error; use crate::{ @@ -33,6 +36,7 @@ static MAIL_PASSWORD_RESET_START: &str = include_str!("../templates/mail_password_reset_start.tera"); static MAIL_PASSWORD_RESET_SUCCESS: &str = include_str!("../templates/mail_password_reset_success.tera"); +static MAIL_DATETIME_FORMAT: &str = "%A, %B %d, %Y at %r"; #[derive(Error, Debug)] pub enum TemplateError { @@ -42,13 +46,31 @@ pub enum TemplateError { TemplateError(#[from] tera::Error), } -pub fn get_base_tera( +struct NoOp(&'static str); + +impl Function for NoOp { + fn call(&self, _args: &HashMap) -> tera::Result { + Err(tera::Error::function_not_found(self.0)) + } +} + +/// Return a safe instance of Tera, as Tera is vulnerable to `get_env()` function exploit. +/// See: https://github.com/Keats/tera/issues/677 +pub(crate) fn safe_tera() -> Tera { + let mut tera = Tera::default(); + let noop = NoOp("get_env"); + tera.register_function(noop.0, noop); + + tera +} + +fn get_base_tera( external_context: Option, session: Option<&Session>, ip_address: Option<&str>, device_info: Option<&str>, ) -> Result<(Tera, Context), TemplateError> { - let mut tera = Tera::default(); + let mut tera = safe_tera(); let mut context = external_context.unwrap_or_default(); tera.add_raw_template("base.tera", MAIL_BASE)?; tera.add_raw_template("macros.tera", MAIL_MACROS)?; @@ -57,7 +79,7 @@ pub fn get_base_tera( let now = Utc::now(); let current_year = format!("{:04}", now.year()); context.insert("current_year", ¤t_year); - context.insert("date_now", &now.format("%A, %B %d, %Y at %r").to_string()); + context.insert("date_now", &now.format(MAIL_DATETIME_FORMAT).to_string()); if let Some(current_session) = session { let device_info = ¤t_session.device_info; @@ -218,7 +240,7 @@ pub fn new_device_login_mail( tera.add_raw_template("mail_base", MAIL_BASE)?; context.insert( "date_now", - &created.format("%A, %B %d, %Y at %r").to_string(), + &created.format(MAIL_DATETIME_FORMAT).to_string(), ); tera.add_raw_template("mail_new_device_login", MAIL_NEW_DEVICE_LOGIN)?; @@ -270,9 +292,9 @@ pub fn gateway_reconnected_mail( pub fn email_mfa_activation_mail( user: &User, code: &str, - session: &Session, + session: Option<&Session>, ) -> Result { - let (mut tera, mut context) = get_base_tera(None, Some(session), None, None)?; + let (mut tera, mut context) = get_base_tera(None, session, None, None)?; let timeout = server_config().mfa_code_timeout; // zero-pad code to make sure it's always 6 digits long context.insert("code", &format!("{code:0>6}")); @@ -450,4 +472,12 @@ mod test { None )); } + + #[test] + fn dg25_8_server_side_template_injection() { + let mut tera = safe_tera(); + tera.add_raw_template("text", "PATH={{ get_env(name=\"PATH\") }}") + .unwrap(); + assert!(tera.render("text", &Context::new()).is_err()); + } } diff --git a/crates/defguard_core/src/updates.rs b/crates/defguard_core/src/updates.rs index 934a2a44c9..2cbfb96d71 100644 --- a/crates/defguard_core/src/updates.rs +++ b/crates/defguard_core/src/updates.rs @@ -1,4 +1,4 @@ -use std::env; +use std::{env, time::Duration}; use chrono::NaiveDate; use semver::Version; @@ -8,6 +8,7 @@ use crate::global_value; const PRODUCT_NAME: &str = "Defguard"; const UPDATES_URL: &str = "https://pkgs.defguard.net/api/update/check"; const VERSION: &str = env!("CARGO_PKG_VERSION"); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Deserialize, Debug, Serialize)] #[cfg_attr(test, derive(Clone))] @@ -31,21 +32,22 @@ async fn fetch_update() -> Result { let response = reqwest::Client::new() .post(UPDATES_URL) .json(&body) - .timeout(std::time::Duration::from_secs(10)) + .timeout(REQUEST_TIMEOUT) .send() .await?; Ok(response.json::().await?) } pub(crate) async fn do_new_version_check() -> Result<(), anyhow::Error> { - debug!("Checking for new version of Defguard ..."); + debug!("Checking for new version of Defguard."); let update = fetch_update().await?; let current_version = Version::parse(VERSION)?; let new_version = Version::parse(&update.version)?; if new_version > current_version { if update.critical { warn!( - "There is a new critical Defguard update available: {} (Released on {}). It's recommended to update as soon as possible.", + "There is a new critical Defguard update available: {} (Released on {}). It's \ + recommended to update as soon as possible.", update.version, update.release_date ); } else { diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index c972e819ce..0e608d1275 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -42,9 +42,10 @@ pub async fn run_utility_thread( let mut enterprise_enabled = is_enterprise_enabled(); let directory_sync_task = || async { - if let Err(e) = do_directory_sync(pool, &wireguard_tx) - .instrument(info_span!("directory_sync_task")) - .await + if let Err(e) = Box::pin( + do_directory_sync(pool, &wireguard_tx).instrument(info_span!("directory_sync_task")), + ) + .await { error!("There was an error while performing directory sync job: {e:?}",); } @@ -83,7 +84,7 @@ pub async fn run_utility_thread( .await { error!("Failed to check expired ACL rules: {err}"); - }; + } }; directory_sync_task().await; diff --git a/crates/defguard_core/src/version.rs b/crates/defguard_core/src/version.rs new file mode 100644 index 0000000000..c4eda787d1 --- /dev/null +++ b/crates/defguard_core/src/version.rs @@ -0,0 +1,301 @@ +use std::{ + collections::HashSet, + hash::{Hash, Hasher}, + sync::{Arc, RwLock}, +}; + +use chrono::{NaiveDateTime, TimeDelta, Utc}; +use serde::Serialize; +use tonic::{Status, service::Interceptor}; + +use defguard_version::{ComponentInfo, Version, is_version_lower}; + +const MIN_PROXY_VERSION: Version = Version::new(1, 5, 0); +pub const MIN_GATEWAY_VERSION: Version = Version::new(1, 5, 0); +static OUTDATED_COMPONENT_LIFETIME: TimeDelta = TimeDelta::hours(1); + +/// Checks if Defguard Proxy version meets minimum version requirements. +pub(crate) fn is_proxy_version_supported(version: Option<&Version>) -> bool { + let Some(version) = version else { + error!( + "Missing proxy component version information. This most likely means that proxy \ + component uses older, unsupported version. Minimal supported proxy version is \ + {MIN_PROXY_VERSION}." + ); + return false; + }; + + if is_version_lower(version, &MIN_PROXY_VERSION) { + error!( + "Proxy version {version} is not supported. Minimal supported proxy version is \ + {MIN_PROXY_VERSION}." + ); + + return false; + } + + info!("Proxy version {version} is supported"); + true +} + +#[derive(Clone)] +pub struct GatewayVersionInterceptor { + min_version: Version, + incompatible_components: Arc>, +} + +impl GatewayVersionInterceptor { + #[must_use] + pub fn new( + min_version: Version, + incompatible_components: Arc>, + ) -> Self { + Self { + min_version, + incompatible_components, + } + } + + #[must_use] + fn is_version_supported(&self, version: Option<&Version>) -> bool { + let Some(version) = version else { + error!( + "Missing gateway version information. This most likely means that gateway component uses \ + older, unsupported version. Minimal supported version is {}.", + self.min_version, + ); + return false; + }; + + if is_version_lower(version, &self.min_version) { + error!( + "Gateway version {version} is not supported. Minimal supported gateway version is {}.", + self.min_version + ); + return false; + } + + debug!("Gateway version {version} is supported"); + true + } +} + +impl Interceptor for GatewayVersionInterceptor { + fn call(&mut self, request: tonic::Request<()>) -> Result, Status> { + let maybe_info = ComponentInfo::from_metadata(request.metadata()); + let version = maybe_info.as_ref().map(|info| &info.version); + let maybe_hostname = request + .metadata() + .get("hostname") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let maybe_network = request + .metadata() + .get("gateway_network_id") + .and_then(|v| v.to_str().ok()) + .map(String::from); + if self.is_version_supported(version) { + IncompatibleComponents::remove_gateway(&self.incompatible_components, &maybe_network); + } else { + let data = + IncompatibleGatewayData::new(version.cloned(), maybe_hostname, maybe_network); + data.insert(&self.incompatible_components); + let msg = match version { + Some(version) => format!("Version {version} not supported"), + None => "Missing version headers".to_string(), + }; + return Err(Status::failed_precondition(msg)); + } + + Ok(request) + } +} + +#[derive(Debug, Default, Clone, Serialize)] +pub struct IncompatibleComponents { + pub gateways: HashSet, + pub proxy: Option, +} + +impl IncompatibleComponents { + /// Clears proxy metadata while avoiding write-locking the structure unnecessarily. + pub fn remove_proxy(components: &Arc>) -> bool { + if components + .read() + .expect("Failed to read-lock IncompatibleComponents") + .proxy + .is_none() + { + return false; + } + components + .write() + .expect("Failed to write-lock IncompatibleComponents") + .proxy = None; + + true + } + + /// Removes metadata from the HashSet while avoiding write-locking the structure unnecessarily. + pub fn remove_gateway(components: &Arc>, network_id: &Option) -> bool { + if !components + .read() + .expect("Failed to read-lock IncompatibleComponents") + .gateways + .iter() + .any(|gw| &gw.network_id == network_id) + { + return false; + } + components + .write() + .expect("Failed to write-lock IncompatibleComponents") + .gateways + .retain(|gw| &gw.network_id != network_id); + + true + } + + /// Removes expired (older than `OUTDATED_COMPONENT_LIFETIME`) components. + /// Avoids unnecessary write-locks. + pub fn remove_expired(components: &Arc>) -> bool { + let now = Utc::now().naive_utc(); + if !Self::contains_expired(components, now) { + return false; + } + + let mut components = components + .write() + .expect("Failed to write-lock IncompatibleComponents"); + components.proxy = components + .proxy + .take() + .filter(|proxy| (now - proxy.created) <= OUTDATED_COMPONENT_LIFETIME); + components + .gateways + .retain(|gateway| (now - gateway.created) <= OUTDATED_COMPONENT_LIFETIME); + + true + } + + /// Returns true if expired (older than `OUTDATED_COMPONENT_LIFETIME`) components exist. + fn contains_expired(components: &Arc>, now: NaiveDateTime) -> bool { + if components + .read() + .expect("Failed to read-lock IncompatibleComponents") + .proxy + .as_ref() + .filter(|proxy| (now - proxy.created) > OUTDATED_COMPONENT_LIFETIME) + .is_some() + { + return true; + } + + if components + .read() + .expect("Failed to read-lock IncompatibleComponents") + .gateways + .iter() + .any(|gateway| (now - gateway.created) > OUTDATED_COMPONENT_LIFETIME) + { + return true; + } + + false + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct IncompatibleGatewayData { + pub version: Option, + pub hostname: Option, + pub network_id: Option, + created: NaiveDateTime, +} + +impl PartialEq for IncompatibleGatewayData { + fn eq(&self, other: &Self) -> bool { + self.version == other.version && self.network_id == other.network_id + } +} + +impl Eq for IncompatibleGatewayData {} + +impl Hash for IncompatibleGatewayData { + fn hash(&self, state: &mut H) { + self.version.hash(state); + self.network_id.hash(state); + } +} + +impl IncompatibleGatewayData { + pub fn new( + version: Option, + hostname: Option, + network_id: Option, + ) -> Self { + let created = Utc::now().naive_utc(); + Self { + version, + hostname, + created, + network_id, + } + } + + /// Inserts metadata into the HashSet while avoiding write-locking the structure unnecessarily. + pub fn insert(&self, components: &Arc>) -> bool { + if components + .read() + .expect("Failed to read-lock IncompatibleComponents") + .gateways + .contains(self) + { + return false; + } + components + .write() + .expect("Failed to write-lock IncompatibleComponents") + .gateways + .insert(self.clone()) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct IncompatibleProxyData { + pub version: Option, + created: NaiveDateTime, +} + +impl PartialEq for IncompatibleProxyData { + fn eq(&self, other: &Self) -> bool { + self.version == other.version + } +} + +impl Eq for IncompatibleProxyData {} + +impl IncompatibleProxyData { + pub fn new(version: Option) -> Self { + let created = Utc::now().naive_utc(); + Self { version, created } + } + + /// Inserts metadata while avoiding write-locking the structure unnecessarily. + pub fn insert(&self, components: &Arc>) -> bool { + if components + .read() + .expect("Failed to read-lock IncompatibleComponents") + .proxy + .as_ref() + == Some(self) + { + return false; + } + components + .write() + .expect("Failed to write-lock IncompatibleComponents") + .proxy = Some(self.clone()); + true + } +} diff --git a/crates/defguard_core/src/wg_config.rs b/crates/defguard_core/src/wg_config.rs index f634073b9b..4dcdc47ff4 100644 --- a/crates/defguard_core/src/wg_config.rs +++ b/crates/defguard_core/src/wg_config.rs @@ -112,7 +112,7 @@ pub(crate) fn parse_wireguard_config( false, false, LocationMfaMode::Disabled, - )?; + ); network.pubkey = pubkey; network.prvkey = prvkey.to_string(); diff --git a/crates/defguard_core/src/wireguard_peer_disconnect.rs b/crates/defguard_core/src/wireguard_peer_disconnect.rs index e0e0dccd54..06be7734d9 100644 --- a/crates/defguard_core/src/wireguard_peer_disconnect.rs +++ b/crates/defguard_core/src/wireguard_peer_disconnect.rs @@ -172,7 +172,7 @@ pub async fn run_periodic_peer_disconnect( .and_then(|(ip, _)| IpAddr::from_str(ip).ok()) // endpoint is a `text` column in the db so we have to // handle potential parsing issues here - .unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); let event = InternalEvent::DesktopClientMfaDisconnected { context: InternalEventContext::new(user.id, user.username, ip, device), location: location.clone(), diff --git a/crates/defguard_core/templates/macros.tera b/crates/defguard_core/templates/macros.tera index a05ab8ed03..1d82a9a0e8 100644 --- a/crates/defguard_core/templates/macros.tera +++ b/crates/defguard_core/templates/macros.tera @@ -53,6 +53,59 @@ {% endmacro text_section %} +{% macro inline_image(src, height="100px", width="100px", alt="") %} +

+ + + + + + +
+
+ + + + + + +
+ + + + + + +
+
+ {{ alt }} +
+
+
+
+
+
+{% endmacro inline_image %} + {% macro paragraph(content="", color="#222", font_size="12px", align="left", line_height="120%", font_weight="400") %}

{% endmacro title %} + +{% macro button_link(href="", text="") %} +

+ {{ text }} + +

+{% endmacro button_link %} diff --git a/crates/defguard_core/templates/mail_desktop_start.tera b/crates/defguard_core/templates/mail_desktop_start.tera index a23f84ed56..671e7186d1 100644 --- a/crates/defguard_core/templates/mail_desktop_start.tera +++ b/crates/defguard_core/templates/mail_desktop_start.tera @@ -5,6 +5,11 @@ macros::paragraph(content="You're receiving this email to configure a new desktop client."), macros::paragraph(content="Please paste this URL and token in your desktop client:"), macros::paragraph(content="URL: " ~ url), -macros::paragraph(content="Token: " ~ token)] %} +macros::paragraph(content="Token: " ~ token), +macros::spacer(height="20px"), +macros::paragraph(content="Or use link below"), +macros::spacer(height="20px"), +macros::button_link(href="defguard://addinstance?token=" ~ token ~ "&url=" ~ url, text="Configure your desktop client") +] %} {{ macros::text_section(content_array=section_content)}} {% endblock %} diff --git a/crates/defguard_core/templates/mail_enrollment_start.tera b/crates/defguard_core/templates/mail_enrollment_start.tera index e449167728..084568add7 100644 --- a/crates/defguard_core/templates/mail_enrollment_start.tera +++ b/crates/defguard_core/templates/mail_enrollment_start.tera @@ -37,24 +37,10 @@ Desktop client can still be activated later, by accessing your profile in defgua macros::paragraph(content= "If you wish to do enrollment via Web, please copy & paste the following URL in your browser: "), macros::link(content=link_url, href=link_url), macros::paragraph(content="Please note that: this option is only valid for 24 hours after receiving this email. When the enrollment process starts user will have 10 minutes to complete the process."), -macros::paragraph(content="You can also click the button below to start the enrollment:"), +macros::paragraph(content="You can also click the buttons below to start the enrollment on website or within desktop client:"), +macros::button_link(href=link_url, text="Start enrollment"), +macros::spacer(height="20px"), +macros::button_link(href="defguard://addinstance?token=" ~ token ~ "&url=" ~ enrollment_url, text="Enroll with desktop client"), ] %} {{ macros::text_section(content_array=section_content)}} -

Start enrollment

{% endblock %} diff --git a/crates/defguard_core/tests/integration/acl.rs b/crates/defguard_core/tests/integration/api/acl.rs similarity index 99% rename from crates/defguard_core/tests/integration/acl.rs rename to crates/defguard_core/tests/integration/api/acl.rs index b1a1b223a4..9bf2ffad6f 100644 --- a/crates/defguard_core/tests/integration/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl.rs @@ -21,10 +21,11 @@ use sqlx::{ }; use tokio::net::TcpListener; -use crate::common::{ - authenticate_admin, client::TestClient, exceed_enterprise_limits, init_config, - initialize_users, make_base_client, make_test_client, omit_id, setup_pool, +use super::common::{ + authenticate_admin, client::TestClient, exceed_enterprise_limits, make_base_client, + make_test_client, omit_id, setup_pool, }; +use crate::common::{init_config, initialize_users}; async fn make_client_v2(pool: PgPool, config: DefGuardConfig) -> TestClient { let listener = TcpListener::bind("127.0.0.1:0") @@ -426,7 +427,6 @@ async fn test_related_objects(_: PgPoolOptions, options: PgConnectOptions) { false, LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); @@ -767,7 +767,6 @@ async fn test_rule_delete_state_applied(_: PgPoolOptions, options: PgConnectOpti false, LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); diff --git a/crates/defguard_core/tests/integration/api_tokens.rs b/crates/defguard_core/tests/integration/api/api_tokens.rs similarity index 71% rename from crates/defguard_core/tests/integration/api_tokens.rs rename to crates/defguard_core/tests/integration/api/api_tokens.rs index f7805efa50..d707abe0c8 100644 --- a/crates/defguard_core/tests/integration/api_tokens.rs +++ b/crates/defguard_core/tests/integration/api/api_tokens.rs @@ -1,6 +1,6 @@ use chrono::Utc; use defguard_core::{ - db::UserInfo, + db::{Group, UserInfo, models::group::Permission}, enterprise::{ db::models::api_tokens::{ApiToken, ApiTokenInfo}, handlers::api_tokens::{AddApiTokenData, RenameRequest}, @@ -9,9 +9,11 @@ use defguard_core::{ }; use reqwest::{StatusCode, header::HeaderName}; use serde::Deserialize; +use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_client, make_client_with_state, setup_pool}; +use super::common::{make_client, make_client_with_state, setup_pool}; +use crate::api::common::fetch_user_details; #[sqlx::test] async fn test_normal_user_cannot_access_token_endpoints( @@ -84,6 +86,11 @@ async fn test_normal_user_cannot_use_token_auth(_: PgPoolOptions, options: PgCon assert_eq!(response.status(), StatusCode::FORBIDDEN); } +#[derive(Deserialize)] +struct NewTokenResponse { + token: String, +} + #[sqlx::test] async fn test_admin_user_can_manage_api_tokens(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -96,10 +103,6 @@ async fn test_admin_user_can_manage_api_tokens(_: PgPoolOptions, options: PgConn assert_eq!(response.status(), StatusCode::OK); // create some API tokens - #[derive(Deserialize)] - struct NewTokenResponse { - token: String, - } let response = client .post("/api/v1/user/admin/api_token") .json(&AddApiTokenData { @@ -209,10 +212,6 @@ async fn test_admin_user_can_use_api_tokens_to_authenticate( assert_eq!(response.status(), StatusCode::OK); // create API token - #[derive(Deserialize)] - struct NewTokenResponse { - token: String, - } let response = client .post("/api/v1/user/admin/api_token") .json(&AddApiTokenData { @@ -281,3 +280,98 @@ async fn test_admin_user_can_use_api_tokens_to_authenticate( let user: UserInfo = response.json().await; assert_eq!(user.username, "admin"); } + +#[sqlx::test] +async fn dg25_3_test_token_invalidation(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let client = make_client(pool.clone()).await; + + // log in as admin user + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // add another user to admin group + let admin_groups = Group::find_by_permission(&pool, Permission::IsAdmin) + .await + .unwrap(); + let admin_group = admin_groups.first().unwrap(); + + let response = client + .post(format!("/api/v1/group/{}", admin_group.name)) + .json(&json!({"username": "hpotter"})) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // switch to second admin account + let response = client.post("/api/v1/auth/logout").send().await; + assert_eq!(response.status(), StatusCode::OK); + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // create api token + let response = client + .post("/api/v1/user/hpotter/api_token") + .json(&AddApiTokenData { + name: "dummy token 1".into(), + }) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let token = response + .into_inner() + .json::() + .await + .unwrap() + .token; + + // log out + let response = client.post("/api/v1/auth/logout").send().await; + assert_eq!(response.status(), StatusCode::OK); + + // use token for authentication + let response = client + .get("/api/v1/me") + .header( + HeaderName::from_static("authorization"), + &format!("Bearer {token}"), + ) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // log in as first admin and disable second user + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + let mut user_details = fetch_user_details(&client, "hpotter").await; + user_details.user.is_active = false; + + let response = client + .put("/api/v1/user/hpotter") + .json(&user_details.user) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let user_details = fetch_user_details(&client, "hpotter").await; + assert!(!user_details.user.is_active); + + // log out + let response = client.post("/api/v1/auth/logout").send().await; + assert_eq!(response.status(), StatusCode::OK); + + // cannot use token for authentication anymore + let response = client + .get("/api/v1/me") + .header( + HeaderName::from_static("authorization"), + &format!("Bearer {token}"), + ) + .send() + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} diff --git a/crates/defguard_core/tests/integration/auth.rs b/crates/defguard_core/tests/integration/api/auth.rs similarity index 87% rename from crates/defguard_core/tests/integration/auth.rs rename to crates/defguard_core/tests/integration/api/auth.rs index 4ac9279557..f7150b9a36 100644 --- a/crates/defguard_core/tests/integration/auth.rs +++ b/crates/defguard_core/tests/integration/api/auth.rs @@ -20,7 +20,7 @@ use totp_lite::{Sha1, totp_custom}; use webauthn_authenticator_rs::{WebauthnAuthenticator, prelude::Url, softpasskey::SoftPasskey}; use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse}; -use crate::common::{ +use super::common::{ X_FORWARDED_FOR, fetch_user_details, make_client, make_client_with_db, make_client_with_state, make_test_client, setup_pool, }; @@ -32,6 +32,22 @@ pub struct RecoveryCodes { codes: Option>, } +#[sqlx::test] +async fn dg25_19_clickjacking_vulnerability(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let client = make_client(pool).await; + + let response = client.get("/").send().await; + let headers = response.headers(); + let csp_header = headers.get("content-security-policy").unwrap(); + let csp_value = csp_header.to_str().unwrap(); + assert!( + csp_value.contains("frame-ancestors 'none'"), + "CSP header should block all iframes with 'none' directive" + ); +} + #[sqlx::test] async fn test_logout(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -268,6 +284,51 @@ async fn test_totp(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); } +#[sqlx::test] +async fn dg25_15_test_totp_brute_force(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let client = make_client(pool).await; + + // login + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // new TOTP secret + let response = client.post("/api/v1/auth/totp/init").send().await; + assert_eq!(response.status(), StatusCode::OK); + let auth_totp: AuthTotp = response.json().await; + + // enable TOTP + let code = totp_code(&auth_totp); + let response = client.post("/api/v1/auth/totp").json(&code).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // enable MFA + let response = client.put("/api/v1/auth/mfa").send().await; + assert_eq!(response.status(), StatusCode::OK); + + // login again, this time a different status code is returned + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // provide wrong TOTP code more than 5 times in a row + let code = AuthCode::new("0"); + for i in 0..10 { + let response = client + .post("/api/v1/auth/totp/verify") + .json(&code) + .send() + .await; + if i >= 5 { + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + } else { + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + } +} + static EMAIL_CODE_REGEX: &str = r"(?\d{6})"; fn extract_email_code(content: &str) -> &str { let re = regex::Regex::new(EMAIL_CODE_REGEX).unwrap(); @@ -420,6 +481,69 @@ async fn test_email_mfa(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); } +#[sqlx::test] +async fn dg25_15_test_email_mfa_brute_force(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, state) = make_client_with_state(pool).await; + let pool = state.pool; + let mut mail_rx = state.mail_rx; + + // try to initialize email MFA setup before logging in + let response = client.post("/api/v1/auth/email/init").send().await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // login + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + // remove login confirmation email from queue + let _mail = mail_rx.try_recv().unwrap(); + + // add dummy SMTP settings + let mut settings = Settings::get_current_settings(); + settings.smtp_server = Some("smtp_server".into()); + settings.smtp_port = Some(587); + settings.smtp_sender = Some("smtp@sender.pl".into()); + update_current_settings(&pool, settings).await.unwrap(); + + // initialize email MFA setup + let response = client.post("/api/v1/auth/email/init").send().await; + assert_eq!(response.status(), StatusCode::OK); + let mail = mail_rx.try_recv().unwrap(); + assert_eq!(mail.to, "h.potter@hogwart.edu.uk"); + assert_eq!(mail.subject, "Your Multi-Factor Authentication Activation"); + let code = extract_email_code(&mail.content); + + // finish setup + let code = AuthCode::new(code); + let response = client.post("/api/v1/auth/email").json(&code).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // enable MFA + let response = client.put("/api/v1/auth/mfa").send().await; + assert_eq!(response.status(), StatusCode::OK); + + // login again, this time a different status code is returned + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // provide wrong code more than 5 times in a row + let code = AuthCode::new("0"); + for i in 0..10 { + let response = client + .post("/api/v1/auth/email/verify") + .json(&code) + .send() + .await; + if i >= 5 { + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + } else { + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + } +} + #[sqlx::test] async fn test_webauthn(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/common/client.rs b/crates/defguard_core/tests/integration/api/common/client.rs similarity index 94% rename from crates/defguard_core/tests/integration/common/client.rs rename to crates/defguard_core/tests/integration/api/common/client.rs index bcf680cca3..61a45135dc 100644 --- a/crates/defguard_core/tests/integration/common/client.rs +++ b/crates/defguard_core/tests/integration/api/common/client.rs @@ -9,7 +9,7 @@ use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT}, redirect::Policy, }; -use tokio::{net::TcpListener, sync::mpsc::UnboundedReceiver}; +use tokio::{net::TcpListener, sync::mpsc::UnboundedReceiver, task::JoinHandle}; pub struct TestClient { client: Client, @@ -18,6 +18,7 @@ pub struct TestClient { // Has to live during whole test #[allow(dead_code)] api_event_rx: UnboundedReceiver, + api_task_handle: JoinHandle<()>, } impl TestClient { @@ -29,7 +30,7 @@ impl TestClient { ) -> Self { let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { + let api_task_handle = tokio::spawn(async move { let server = serve( listener, app.into_make_service_with_connect_info::(), @@ -54,6 +55,7 @@ impl TestClient { jar, port, api_event_rx, + api_task_handle, } } @@ -122,6 +124,13 @@ impl TestClient { } } +impl Drop for TestClient { + fn drop(&mut self) { + // explicitly stop spawned API server task + self.api_task_handle.abort(); + } +} + pub struct RequestBuilder { builder: reqwest::RequestBuilder, } diff --git a/crates/defguard_core/tests/integration/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs similarity index 87% rename from crates/defguard_core/tests/integration/common/mod.rs rename to crates/defguard_core/tests/integration/api/common/mod.rs index e167b3146d..ea5b3393ab 100644 --- a/crates/defguard_core/tests/integration/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -1,12 +1,10 @@ pub(crate) mod client; -use std::{ - str::FromStr, - sync::{Arc, Mutex}, -}; +use std::sync::{Arc, Mutex}; +pub use defguard_core::db::setup_pool; use defguard_core::{ - SERVER_CONFIG, + VERSION, auth::failed_login::FailedLoginMap, build_webapp, config::DefGuardConfig, @@ -16,12 +14,12 @@ use defguard_core::{ }, enterprise::license::{License, set_cached_license}, events::ApiEvent, - grpc::{GatewayMap, WorkerState}, + grpc::{WorkerState, gateway::map::GatewayMap}, handlers::Auth, mail::Mail, }; -use reqwest::{StatusCode, Url, header::HeaderName}; -use secrecy::ExposeSecret; +use reqwest::{StatusCode, header::HeaderName}; +use semver::Version; use serde::de::DeserializeOwned; use serde_json::{Value, json}; use sqlx::PgPool; @@ -33,9 +31,8 @@ use tokio::{ }, }; -pub use defguard_core::db::setup_pool; - use self::client::TestClient; +use crate::common::{init_config, initialize_users}; #[allow(clippy::declare_interior_mutable_const)] pub const X_FORWARDED_HOST: HeaderName = HeaderName::from_static("x-forwarded-host"); @@ -44,34 +41,6 @@ pub const X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for #[allow(clippy::declare_interior_mutable_const)] pub const X_FORWARDED_URI: HeaderName = HeaderName::from_static("x-forwarded-uri"); -/// Allows overriding the default DefGuard URL for tests, as during the tests, the server has a random port, making the URL unpredictable beforehand. -// TODO: Allow customizing the whole config, not just the URL -pub(crate) fn init_config(custom_defguard_url: Option<&str>) -> DefGuardConfig { - let url = custom_defguard_url.unwrap_or("http://localhost:8000"); - let mut config = DefGuardConfig::new_test_config(); - config.url = Url::from_str(url).unwrap(); - let _ = SERVER_CONFIG.set(config.clone()); - config -} - -pub(crate) async fn initialize_users(pool: &PgPool, config: &DefGuardConfig) { - User::init_admin_user(pool, config.default_admin_password.expose_secret()) - .await - .unwrap(); - - User::new( - "hpotter", - Some("pass123"), - "Potter", - "Harry", - "h.potter@hogwart.edu.uk", - None, - ) - .save(pool) - .await - .unwrap(); -} - pub(crate) struct ClientState { pub pool: PgPool, pub worker_state: Arc>, @@ -122,6 +91,7 @@ pub(crate) async fn make_base_client( // Permanent license None, None, + None, ); set_cached_license(Some(license)); @@ -159,6 +129,8 @@ pub(crate) async fn make_base_client( pool, failed_logins, api_event_tx, + Version::parse(VERSION).unwrap(), + Default::default(), ); ( diff --git a/crates/defguard_core/tests/integration/enrollment.rs b/crates/defguard_core/tests/integration/api/enrollment.rs similarity index 98% rename from crates/defguard_core/tests/integration/enrollment.rs rename to crates/defguard_core/tests/integration/api/enrollment.rs index 71eeb1eceb..7068650a3d 100644 --- a/crates/defguard_core/tests/integration/enrollment.rs +++ b/crates/defguard_core/tests/integration/api/enrollment.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{fetch_user_details, make_client_with_db, setup_pool}; +use super::common::{fetch_user_details, make_client_with_db, setup_pool}; #[sqlx::test] async fn test_initialize_enrollment(_: PgPoolOptions, options: PgConnectOptions) { diff --git a/crates/defguard_core/tests/integration/enterprise_settings.rs b/crates/defguard_core/tests/integration/api/enterprise_settings.rs similarity index 59% rename from crates/defguard_core/tests/integration/enterprise_settings.rs rename to crates/defguard_core/tests/integration/api/enterprise_settings.rs index 821899c6b0..675fc60a38 100644 --- a/crates/defguard_core/tests/integration/enterprise_settings.rs +++ b/crates/defguard_core/tests/integration/api/enterprise_settings.rs @@ -9,7 +9,7 @@ use reqwest::StatusCode; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{exceed_enterprise_limits, make_network, make_test_client, setup_pool}; +use super::common::{exceed_enterprise_limits, make_network, make_test_client, setup_pool}; #[sqlx::test] async fn test_only_enterprise_can_modify(_: PgPoolOptions, options: PgConnectOptions) { @@ -216,3 +216,142 @@ async fn test_regular_user_device_management(_: PgPoolOptions, options: PgConnec assert_eq!(response.status(), StatusCode::OK); } + +#[sqlx::test] +async fn dg25_12_test_enforce_client_activation_only(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // admin login + let (client, _) = make_test_client(pool).await; + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + exceed_enterprise_limits(&client).await; + + // create network + let response = client + .post("/api/v1/network") + .json(&make_network()) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // disable manual device management + let settings = EnterpriseSettings { + admin_device_management: false, + disable_all_traffic: false, + only_client_activation: true, + }; + let response = client + .patch("/api/v1/settings_enterprise") + .json(&settings) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // make sure admin can manage devices + let device = json!({ + "name": "device", + "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", + }); + let response = client + .post("/api/v1/device/hpotter") + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // ensure normal users can't manage devices + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // add + let device = json!({ + "name": "userdevice", + "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + }); + let response = client + .post("/api/v1/device/hpotter") + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // modify + let device = json!({ + "name": "modifieddevice", + "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + }); + let response = client.put("/api/v1/device/2").json(&device).send().await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // delete + let device = json!({ + "name": "modifieddevice", + "wireguard_pubkey": "AJwxGkzvVVn5Q1xjpCDFo5RJSU9KOPHeoEixYaj+20M=", + }); + let response = client.put("/api/v1/device/2").json(&device).send().await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[sqlx::test] +async fn dg25_13_test_disable_device_config(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // admin login + let (client, _) = make_test_client(pool).await; + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + exceed_enterprise_limits(&client).await; + + // create network + let response = client + .post("/api/v1/network") + .json(&make_network()) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // disable manual device management + let settings = EnterpriseSettings { + admin_device_management: false, + disable_all_traffic: false, + only_client_activation: true, + }; + let response = client + .patch("/api/v1/settings_enterprise") + .json(&settings) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // add device for normal user + let device = json!({ + "name": "device", + "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", + }); + let response = client + .post("/api/v1/device/hpotter") + .json(&device) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // admin can view device config + let response = client.get("/api/v1/network/1/device/1/config").send().await; + assert_eq!(response.status(), StatusCode::OK); + + // ensure normal users can't access device config + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client.get("/api/v1/network/1/device/1/config").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/defguard_core/tests/integration/forward_auth.rs b/crates/defguard_core/tests/integration/api/forward_auth.rs similarity index 96% rename from crates/defguard_core/tests/integration/forward_auth.rs rename to crates/defguard_core/tests/integration/api/forward_auth.rs index fce53bc90d..83b97c1ba9 100644 --- a/crates/defguard_core/tests/integration/forward_auth.rs +++ b/crates/defguard_core/tests/integration/api/forward_auth.rs @@ -2,7 +2,7 @@ use defguard_core::{SERVER_CONFIG, handlers::Auth}; use reqwest::StatusCode; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{X_FORWARDED_HOST, X_FORWARDED_URI, make_client, setup_pool}; +use super::common::{X_FORWARDED_HOST, X_FORWARDED_URI, make_client, setup_pool}; #[sqlx::test] async fn test_forward_auth(_: PgPoolOptions, options: PgConnectOptions) { diff --git a/crates/defguard_core/tests/integration/group.rs b/crates/defguard_core/tests/integration/api/group.rs similarity index 85% rename from crates/defguard_core/tests/integration/group.rs rename to crates/defguard_core/tests/integration/api/group.rs index 42ea560e02..f882906be3 100644 --- a/crates/defguard_core/tests/integration/group.rs +++ b/crates/defguard_core/tests/integration/api/group.rs @@ -3,7 +3,7 @@ use reqwest::StatusCode; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_test_client, setup_pool}; +use super::common::{make_test_client, setup_pool}; #[sqlx::test] async fn test_create_group(_: PgPoolOptions, options: PgConnectOptions) { @@ -206,3 +206,32 @@ async fn test_modify_last_admin_group(_: PgPoolOptions, options: PgConnectOption let response = client.put("/api/v1/group/admin").json(&data).send().await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + +#[sqlx::test] +async fn test_group_endpoints_access(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // Auth client as normal user without admin access + let (client, _) = make_test_client(pool).await; + + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client.get("/api/v1/group").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = client.get("/api/v1/group/admin").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = client.delete("/api/v1/group/admin").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let data = EditGroupInfo::new("hogwards", vec!["hpotter".into(), "admin".into()], true); + let response = client.post("/api/v1/group").json(&data).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let data = EditGroupInfo::new("admin", vec!["hpotter".into()], true); + let response = client.put("/api/v1/group/admin").json(&data).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/defguard_core/tests/integration/api/mod.rs b/crates/defguard_core/tests/integration/api/mod.rs new file mode 100644 index 0000000000..4891501678 --- /dev/null +++ b/crates/defguard_core/tests/integration/api/mod.rs @@ -0,0 +1,21 @@ +mod acl; +mod api_tokens; +mod auth; +mod common; +mod enrollment; +mod enterprise_settings; +mod forward_auth; +mod group; +mod oauth; +mod openid; +mod openid_login; +mod settings; +mod snat; +mod user; +mod webhook; +mod wireguard; +mod wireguard_network_allowed_groups; +mod wireguard_network_devices; +mod wireguard_network_import; +mod wireguard_network_stats; +mod worker; diff --git a/crates/defguard_core/tests/integration/oauth.rs b/crates/defguard_core/tests/integration/api/oauth.rs similarity index 99% rename from crates/defguard_core/tests/integration/oauth.rs rename to crates/defguard_core/tests/integration/api/oauth.rs index ec5e4363c2..06d86dbe3b 100644 --- a/crates/defguard_core/tests/integration/oauth.rs +++ b/crates/defguard_core/tests/integration/api/oauth.rs @@ -14,7 +14,7 @@ use reqwest::{StatusCode, Url, header::CONTENT_TYPE}; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_client_with_db, setup_pool}; +use super::common::{make_client_with_db, setup_pool}; #[sqlx::test] async fn test_authorize(_: PgPoolOptions, options: PgConnectOptions) { diff --git a/crates/defguard_core/tests/integration/openid.rs b/crates/defguard_core/tests/integration/api/openid.rs similarity index 67% rename from crates/defguard_core/tests/integration/openid.rs rename to crates/defguard_core/tests/integration/api/openid.rs index 30f34ee78c..7b701bba14 100644 --- a/crates/defguard_core/tests/integration/openid.rs +++ b/crates/defguard_core/tests/integration/api/openid.rs @@ -4,7 +4,7 @@ use axum::http::header::ToStrError; use claims::assert_err; use defguard_core::{ db::{ - Id, + Id, User, models::{NewOpenIDClient, oauth2client::OAuth2Client}, }, handlers::Auth, @@ -26,7 +26,7 @@ use rsa::RsaPrivateKey; use serde::Deserialize; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{ +use super::common::{ client::TestClient, make_client, make_client_with_state, make_test_client, setup_pool, }; @@ -610,6 +610,339 @@ async fn test_openid_authorization_code_with_pkce(_: PgPoolOptions, options: PgC .unwrap(); } +#[sqlx::test] +async fn dg25_23_test_openid_client_scope_change_clears_authorizations( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (client, state) = make_client_with_state(pool).await; + let admin = User::find_by_username(&state.pool, "admin") + .await + .unwrap() + .unwrap(); + + // Authenticate admin + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // Create OAuth2 client with initial scopes + let oauth2client = NewOpenIDClient { + name: "Test Client".into(), + redirect_uri: vec!["http://localhost:3000/".into()], + scope: vec!["openid".into(), "email".into()], + enabled: true, + }; + + let response = client + .post("/api/v1/oauth") + .json(&oauth2client) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let oauth2client: OAuth2Client = response.json().await; + + // Authorize the client - simulate user authorization + let response = client + .post(format!( + "/api/v1/oauth/authorize?\ + response_type=code&\ + client_id={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000&\ + scope=openid email&\ + state=ABCDEF&\ + allow=true&\ + nonce=blabla", + oauth2client.client_id + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::FOUND); + + // Verify that the authorization was created + use defguard_core::db::OAuth2AuthorizedApp; + let authorized_app = OAuth2AuthorizedApp::find_by_user_and_oauth2client_id( + &state.pool, + admin.id, + oauth2client.id, + ) + .await + .unwrap(); + assert!( + authorized_app.is_some(), + "Authorization should exist before scope change" + ); + + // Update the client with different scopes + let updated_client = NewOpenIDClient { + name: "Test Client".into(), + redirect_uri: vec!["http://localhost:3000/".into()], + scope: vec!["openid".into(), "profile".into()], // Changed from email to profile + enabled: true, + }; + + let response = client + .put(format!("/api/v1/oauth/{}", oauth2client.client_id)) + .json(&updated_client) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Verify that the authorization was cleared after scope change + let authorized_app_after = OAuth2AuthorizedApp::find_by_user_and_oauth2client_id( + &state.pool, + admin.id, + oauth2client.id, + ) + .await + .unwrap(); + assert!( + authorized_app_after.is_none(), + "Authorization should be cleared after scope change" + ); + + // Test that updating without scope changes does NOT clear authorizations + + // Re-authorize the client + let response = client + .post(format!( + "/api/v1/oauth/authorize?\ + response_type=code&\ + client_id={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000&\ + scope=openid profile&\ + state=ABCDEF2&\ + allow=true&\ + nonce=blabla2", + oauth2client.client_id + )) + .send() + .await; + assert_eq!(response.status(), StatusCode::FOUND); + + // Verify authorization exists again + let authorized_app = OAuth2AuthorizedApp::find_by_user_and_oauth2client_id( + &state.pool, + admin.id, + oauth2client.id, + ) + .await + .unwrap(); + assert!( + authorized_app.is_some(), + "Authorization should exist after re-authorization" + ); + + // Update the client without changing scopes (only name) + let same_scope_update = NewOpenIDClient { + name: "Test Client Updated Name".into(), + redirect_uri: vec!["http://localhost:3000/".into()], + scope: vec!["openid".into(), "profile".into()], // Same scopes + enabled: true, + }; + + let response = client + .put(format!("/api/v1/oauth/{}", oauth2client.client_id)) + .json(&same_scope_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // Verify that the authorization still exists when scopes haven't changed + let authorized_app_preserved = OAuth2AuthorizedApp::find_by_user_and_oauth2client_id( + &state.pool, + admin.id, + oauth2client.id, + ) + .await + .unwrap(); + assert!( + authorized_app_preserved.is_some(), + "Authorization should be preserved when scopes don't change" + ); +} + +#[sqlx::test] +async fn dg25_22_test_respect_openid_scope_in_userinfo( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let (client, state) = make_client_with_state(pool).await; + let mut config = state.config; + + let mut admin = User::find_by_username(&state.pool, "admin") + .await + .unwrap() + .unwrap(); + + admin.phone = Some("+123456789".into()); + admin.save(&state.pool).await.unwrap(); + + let mut rng = rand::thread_rng(); + config.openid_signing_key = RsaPrivateKey::new(&mut rng, 2048).ok(); + + let issuer_url = IssuerUrl::from_url(config.url.clone()); + + // discover OpenID service + let provider_metadata = + CoreProviderMetadata::discover_async(issuer_url, &|r| http_client(r, &client)) + .await + .unwrap(); + + // Create reusable closure for testing different scope configurations + let get_user_claims = |client_scopes: Vec, requested_scopes: Vec| { + let client = &client; + let provider_metadata = provider_metadata.clone(); + async move { + // Authenticate admin + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // Create OAuth2 client with specified scopes + let oauth2client = NewOpenIDClient { + name: "Test client".into(), + redirect_uri: vec![FAKE_REDIRECT_URI.into()], + scope: client_scopes, + enabled: true, + }; + let response = client + .post("/api/v1/oauth") + .json(&oauth2client) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let oauth2client: OAuth2Client = response.json().await; + + // Store client_id for cleanup + let client_id_for_cleanup = oauth2client.client_id.clone(); + + // Create OpenID client + let client_id = ClientId::new(oauth2client.client_id); + let client_secret = ClientSecret::new(oauth2client.client_secret); + let core_client = CoreClient::from_provider_metadata( + provider_metadata, + client_id, + Some(client_secret), + ) + .set_redirect_uri(RedirectUrl::new(FAKE_REDIRECT_URI.into()).unwrap()); + + // Start Authorization Code Flow with PKCE + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let mut auth_request = core_client.authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ); + + // Add requested scopes + for scope in requested_scopes { + auth_request = auth_request.add_scope(Scope::new(scope)); + } + + let (authorize_url, _csrf_state, nonce) = + auth_request.set_pkce_challenge(pkce_challenge).url(); + + // Obtain authorization code + let uri = format!( + "{}?allow=true&{}", + authorize_url.path(), + authorize_url.query().unwrap() + ); + let response = client.post(uri).send().await; + assert_eq!(response.status(), StatusCode::FOUND); + let location = response + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap(); + let (location, query) = location.split_once('?').unwrap(); + assert_eq!(location, FAKE_REDIRECT_URI); + let auth_response: AuthenticationResponse = serde_qs::from_str(query).unwrap(); + + // Exchange authorization code for token + let token_response = core_client + .exchange_code(AuthorizationCode::new(auth_response.code.into())) + .unwrap() + .set_pkce_verifier(pkce_verifier) + .request_async(&|r| http_client(r, client)) + .await + .unwrap(); + + // Verify id token + let id_token_verifier = core_client.id_token_verifier(); + let _id_token_claims = token_response + .extra_fields() + .id_token() + .expect("Server did not return an ID token") + .claims(&id_token_verifier, &nonce) + .unwrap(); + + // Get userinfo claims + let userinfo_claims: UserInfoClaims = + core_client + .user_info(token_response.access_token().clone(), None) + .expect("Missing info endpoint") + .request_async(&|r| http_client(r, client)) + .await + .unwrap(); + + // Clean up - delete the OAuth client + client + .delete(format!("/api/v1/oauth/{}", client_id_for_cleanup)) + .send() + .await; + + userinfo_claims + } + }; + + // Client has phone and email scopes, request phone and email + let claims = get_user_claims( + vec![ + "openid".to_string(), + "phone".to_string(), + "email".to_string(), + ], + vec!["email".to_string(), "phone".to_string()], + ) + .await; + + // Verify claims include both email and phone + assert!(claims.email().is_some()); + assert!(claims.phone_number().is_some()); + + // Client has phone and email scopes, but only request email + let claims = get_user_claims( + vec![ + "openid".to_string(), + "phone".to_string(), + "email".to_string(), + ], + vec!["email".to_string()], + ) + .await; + + // Verify claims include only email, not phone + assert!(claims.email().is_some()); + assert!(claims.phone_number().is_none()); + + // Client has only email scope, request phone + let claims = get_user_claims( + vec!["openid".to_string(), "email".to_string()], + vec!["email".to_string(), "phone".to_string()], + ) + .await; + + // Verify claims include only email since client doesn't have phone scope + assert!(claims.email().is_some()); + assert!(claims.phone_number().is_none()); +} + #[sqlx::test] async fn test_openid_flow_new_login_mail(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/openid_login.rs b/crates/defguard_core/tests/integration/api/openid_login.rs similarity index 98% rename from crates/defguard_core/tests/integration/openid_login.rs rename to crates/defguard_core/tests/integration/api/openid_login.rs index d088318d24..461f8fa840 100644 --- a/crates/defguard_core/tests/integration/openid_login.rs +++ b/crates/defguard_core/tests/integration/api/openid_login.rs @@ -12,7 +12,7 @@ use reqwest::{StatusCode, Url}; use serde::Deserialize; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{exceed_enterprise_limits, make_client, setup_pool}; +use super::common::{exceed_enterprise_limits, make_client, setup_pool}; // Temporarily disabled because of the issue with test_openid_login // async fn make_client_with_real_url() -> TestClient { @@ -56,6 +56,7 @@ async fn test_openid_providers(_: PgPoolOptions, options: PgConnectOptions) { okta_private_jwk: None, directory_sync_group_match: None, username_handling: OpenidUsernameHandling::PruneEmailDomain, + jumpcloud_api_key: None, }; let response = client @@ -94,6 +95,7 @@ async fn test_openid_providers(_: PgPoolOptions, options: PgConnectOptions) { false, Some(Utc::now() - Duration::days(1)), None, + None, ); set_cached_license(Some(new_license)); let response = client.get("/api/v1/openid/auth_info").send().await; diff --git a/crates/defguard_core/tests/integration/settings.rs b/crates/defguard_core/tests/integration/api/settings.rs similarity index 96% rename from crates/defguard_core/tests/integration/settings.rs rename to crates/defguard_core/tests/integration/api/settings.rs index 1952708765..a832b60eb8 100644 --- a/crates/defguard_core/tests/integration/settings.rs +++ b/crates/defguard_core/tests/integration/api/settings.rs @@ -5,7 +5,7 @@ use defguard_core::{ use reqwest::StatusCode; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_client_with_state, setup_pool}; +use super::common::{make_client_with_state, setup_pool}; #[sqlx::test] async fn test_settings(_: PgPoolOptions, options: PgConnectOptions) { diff --git a/crates/defguard_core/tests/integration/snat.rs b/crates/defguard_core/tests/integration/api/snat.rs similarity index 99% rename from crates/defguard_core/tests/integration/snat.rs rename to crates/defguard_core/tests/integration/api/snat.rs index 9fb4f2abb7..a396041d4c 100644 --- a/crates/defguard_core/tests/integration/snat.rs +++ b/crates/defguard_core/tests/integration/api/snat.rs @@ -12,7 +12,7 @@ use defguard_core::{ use reqwest::StatusCode; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{ +use super::common::{ authenticate_admin, exceed_enterprise_limits, make_network, make_test_client, setup_pool, }; diff --git a/crates/defguard_core/tests/integration/user.rs b/crates/defguard_core/tests/integration/api/user.rs similarity index 96% rename from crates/defguard_core/tests/integration/user.rs rename to crates/defguard_core/tests/integration/api/user.rs index f1f8f85c38..a2c6307e59 100644 --- a/crates/defguard_core/tests/integration/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -9,7 +9,7 @@ use reqwest::{StatusCode, header::USER_AGENT}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio_stream::{self as stream, StreamExt}; -use crate::common::{fetch_user_details, make_client, make_network, make_test_client, setup_pool}; +use super::common::{fetch_user_details, make_client, make_network, make_test_client, setup_pool}; #[sqlx::test] async fn test_authenticate(_: PgPoolOptions, options: PgConnectOptions) { @@ -298,25 +298,6 @@ async fn test_crud_user(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); } -#[sqlx::test] -async fn test_admin_group(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let client = make_client(pool).await; - - let auth = Auth::new("hpotter", "pass123"); - let response = client.post("/api/v1/auth").json(&auth).send().await; - assert_eq!(response.status(), StatusCode::OK); - - let response = client.get("/api/v1/group").send().await; - assert_eq!(response.status(), StatusCode::OK); - - let response = client.get("/api/v1/group/admin").send().await; - assert_eq!(response.status(), StatusCode::OK); - - // TODO: check group membership -} - #[sqlx::test] async fn test_check_username(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -621,7 +602,7 @@ async fn test_disable(_: PgPoolOptions, options: PgConnectOptions) { let mut user_details = fetch_user_details(&client, "admin").await; user_details.user.is_active = false; - // disable yourself + // cannot disable yourself let response = client .put("/api/v1/user/admin") .json(&user_details.user) @@ -645,6 +626,7 @@ async fn test_disable(_: PgPoolOptions, options: PgConnectOptions) { // get user let mut user_details = fetch_user_details(&client, "adumbledore").await; assert_eq!(user_details.user.first_name, "Albus"); + assert!(user_details.user.is_active); // disable user user_details.user.is_active = false; @@ -654,6 +636,10 @@ async fn test_disable(_: PgPoolOptions, options: PgConnectOptions) { .send() .await; assert_eq!(response.status(), StatusCode::OK); + + let user_details = fetch_user_details(&client, "adumbledore").await; + assert_eq!(user_details.user.first_name, "Albus"); + assert!(!user_details.user.is_active); } #[sqlx::test] diff --git a/crates/defguard_core/tests/integration/webhook.rs b/crates/defguard_core/tests/integration/api/webhook.rs similarity index 98% rename from crates/defguard_core/tests/integration/webhook.rs rename to crates/defguard_core/tests/integration/api/webhook.rs index 67dab616ac..326592f3e0 100644 --- a/crates/defguard_core/tests/integration/webhook.rs +++ b/crates/defguard_core/tests/integration/api/webhook.rs @@ -5,7 +5,7 @@ use defguard_core::{ use reqwest::StatusCode; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_client, setup_pool}; +use super::common::{make_client, setup_pool}; #[sqlx::test] async fn test_webhooks(_: PgPoolOptions, options: PgConnectOptions) { diff --git a/crates/defguard_core/tests/integration/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs similarity index 77% rename from crates/defguard_core/tests/integration/wireguard.rs rename to crates/defguard_core/tests/integration/api/wireguard.rs index 2f71537a20..becf937611 100644 --- a/crates/defguard_core/tests/integration/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -5,11 +5,17 @@ use defguard_core::{ Device, GatewayEvent, Id, WireguardNetwork, models::{ device::WireguardNetworkDevice, + settings::OpenidUsernameHandling, wireguard::{ DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL, LocationMfaMode, }, }, }, + enterprise::{ + db::models::openid_provider::{DirectorySyncTarget, DirectorySyncUserBehavior}, + handlers::openid_providers::AddProviderData, + license::{get_cached_license, set_cached_license}, + }, handlers::{Auth, GroupInfo, wireguard::WireguardNetworkData}, }; use ipnetwork::IpNetwork; @@ -18,7 +24,9 @@ use reqwest::StatusCode; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_network, make_test_client, setup_pool}; +use super::common::{ + authenticate_admin, exceed_enterprise_limits, make_network, make_test_client, setup_pool, +}; #[sqlx::test] async fn test_network(_: PgPoolOptions, options: PgConnectOptions) { @@ -116,6 +124,185 @@ async fn test_network(_: PgPoolOptions, options: PgConnectOptions) { assert_matches!(event, GatewayEvent::NetworkDeleted(..)); } +#[sqlx::test] +async fn test_location_mfa_mode_validation_create(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _client_state) = make_test_client(pool).await; + authenticate_admin(&client).await; + + exceed_enterprise_limits(&client).await; + + // unset the license + let license = get_cached_license().clone(); + set_cached_license(None); + + let location_data = WireguardNetworkData { + name: "test_location".into(), + address: "10.1.1.0/24".into(), + endpoint: "10.1.1.1".parse().unwrap(), + port: 55555, + allowed_ips: Some("10.1.1.0/24, 10.2.0.1/16, 10.10.10.54/32".into()), + dns: None, + allowed_groups: vec!["admin".into()], + keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL, + peer_disconnect_threshold: DEFAULT_DISCONNECT_THRESHOLD, + acl_enabled: false, + acl_default_allow: false, + location_mfa_mode: LocationMfaMode::External, + }; + + // create network + let response = client + .post("/api/v1/network") + .json(&location_data) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // restore valid license and try again + set_cached_license(license); + let response = client + .post("/api/v1/network") + .json(&location_data) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // add external OpenID provider + let provider_data = AddProviderData { + name: "test".to_string(), + base_url: "https://accounts.google.com".to_string(), + client_id: "client_id".to_string(), + client_secret: "client_secret".to_string(), + display_name: Some("display_name".to_string()), + admin_email: None, + google_service_account_email: None, + google_service_account_key: None, + directory_sync_enabled: false, + directory_sync_interval: 100, + directory_sync_user_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_admin_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_target: DirectorySyncTarget::All.to_string(), + create_account: false, + okta_dirsync_client_id: None, + okta_private_jwk: None, + directory_sync_group_match: None, + username_handling: OpenidUsernameHandling::PruneEmailDomain, + jumpcloud_api_key: None, + }; + + let response = client + .post("/api/v1/openid/provider") + .json(&provider_data) + .send() + .await; + + assert_eq!(response.status(), StatusCode::CREATED); + + // try again + let response = client + .post("/api/v1/network") + .json(&location_data) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); +} + +#[sqlx::test] +async fn test_location_mfa_mode_validation_modify(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _client_state) = make_test_client(pool).await; + authenticate_admin(&client).await; + + let mut location_data = WireguardNetworkData { + name: "test_location".into(), + address: "10.1.1.0/24".into(), + endpoint: "10.1.1.1".parse().unwrap(), + port: 55555, + allowed_ips: Some("10.1.1.0/24, 10.2.0.1/16, 10.10.10.54/32".into()), + dns: None, + allowed_groups: vec!["admin".into()], + keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL, + peer_disconnect_threshold: DEFAULT_DISCONNECT_THRESHOLD, + acl_enabled: false, + acl_default_allow: false, + location_mfa_mode: LocationMfaMode::Disabled, + }; + + // create network + let response = client + .post("/api/v1/network") + .json(&location_data) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + exceed_enterprise_limits(&client).await; + + // unset the license + let license = get_cached_license().clone(); + set_cached_license(None); + + // attempt to modify location + location_data.location_mfa_mode = LocationMfaMode::External; + let response = client + .put("/api/v1/network/1") + .json(&location_data) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // restore valid license and try again + set_cached_license(license); + let response = client + .put("/api/v1/network/1") + .json(&location_data) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // add external OpenID provider + let provider_data = AddProviderData { + name: "test".to_string(), + base_url: "https://accounts.google.com".to_string(), + client_id: "client_id".to_string(), + client_secret: "client_secret".to_string(), + display_name: Some("display_name".to_string()), + admin_email: None, + google_service_account_email: None, + google_service_account_key: None, + directory_sync_enabled: false, + directory_sync_interval: 100, + directory_sync_user_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_admin_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_target: DirectorySyncTarget::All.to_string(), + create_account: false, + okta_dirsync_client_id: None, + okta_private_jwk: None, + directory_sync_group_match: None, + username_handling: OpenidUsernameHandling::PruneEmailDomain, + jumpcloud_api_key: None, + }; + + let response = client + .post("/api/v1/openid/provider") + .json(&provider_data) + .send() + .await; + + assert_eq!(response.status(), StatusCode::CREATED); + + // try again + let response = client + .put("/api/v1/network/1") + .json(&location_data) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); +} + #[sqlx::test] async fn test_device(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/wireguard_network_allowed_groups.rs b/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs similarity index 99% rename from crates/defguard_core/tests/integration/wireguard_network_allowed_groups.rs rename to crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs index 16288fa64f..51ecb04207 100644 --- a/crates/defguard_core/tests/integration/wireguard_network_allowed_groups.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_allowed_groups.rs @@ -14,7 +14,7 @@ use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, }; -use crate::common::{fetch_user_details, make_test_client, setup_pool}; +use super::common::{fetch_user_details, make_test_client, setup_pool}; // setup user groups, test users and devices async fn setup_test_users(pool: &PgPool) -> (Vec>, Vec>) { diff --git a/crates/defguard_core/tests/integration/wireguard_network_devices.rs b/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs similarity index 69% rename from crates/defguard_core/tests/integration/wireguard_network_devices.rs rename to crates/defguard_core/tests/integration/api/wireguard_network_devices.rs index 1867226b06..ae99878995 100644 --- a/crates/defguard_core/tests/integration/wireguard_network_devices.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs @@ -11,7 +11,7 @@ use serde::Deserialize; use serde_json::{Value, json}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_test_client, setup_pool}; +use super::common::{make_test_client, setup_pool}; fn make_network() -> Value { json!({ @@ -47,12 +47,17 @@ fn make_second_network() -> Value { }) } -#[derive(Deserialize)] +#[derive(Debug, Deserialize, PartialEq)] struct IpCheckRes { available: bool, valid: bool, } +#[derive(Deserialize)] +struct SplitIp { + ip: IpAddr, +} + #[sqlx::test] async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -90,10 +95,7 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { // ip suggestions let response = client.get("/api/v1/device/network/ip/1").send().await; assert_eq!(response.status(), StatusCode::OK); - #[derive(Deserialize)] - struct SplitIp { - ip: IpAddr, - } + let ips: Vec = response.json().await; assert_eq!(ips.len(), 1); let network_range = IpNetwork::from_str("10.1.1.1/24").unwrap(); @@ -109,7 +111,8 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { .send() .await; assert_eq!(response.status(), StatusCode::OK); - let res = response.json::().await; + let res = response.json::>().await; + let res = res.first().unwrap(); assert!(res.available); assert!(res.valid); @@ -122,7 +125,8 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { .send() .await; assert_eq!(response.status(), StatusCode::OK); - let res = response.json::().await; + let res = response.json::>().await; + let res = res.first().unwrap(); assert!(!res.available); assert!(res.valid); @@ -135,7 +139,8 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { .send() .await; assert_eq!(response.status(), StatusCode::OK); - let res = response.json::().await; + let res = response.json::>().await; + let res = res.first().unwrap(); assert!(!res.available); assert!(res.valid); @@ -148,7 +153,8 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { .send() .await; assert_eq!(response.status(), StatusCode::OK); - let res = response.json::().await; + let res = response.json::>().await; + let res = res.first().unwrap(); assert!(!res.available); assert!(!res.valid); @@ -272,3 +278,108 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { .unwrap(); assert!(device.is_none()); } + +#[sqlx::test] +async fn test_device_ip_validation(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _client_state) = make_test_client(pool).await; + + let auth = Auth::new("admin", "pass123"); + let response = &client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // create location + let location = json!({ + "name": "test location", + "address": "10.1.1.1/24, 10.2.2.1/24, 10.3.3.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + "allowed_groups": [], + "keepalive_interval": 25, + "peer_disconnect_threshold": 300, + "acl_enabled": false, + "acl_default_allow": false, + "location_mfa_mode": "disabled" + }); + let response = client.post("/api/v1/network").json(&location).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let location: WireguardNetwork = response.json().await; + let location_id = location.id; + + // IP suggestions + let response = client + .get(format!("/api/v1/device/network/ip/{location_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let ips: Vec = response.json().await; + assert_eq!(ips.len(), 3); + let subnet_1 = IpNetwork::from_str("10.1.1.1/24").unwrap(); + assert!(subnet_1.contains(ips[0].ip)); + let subnet_2 = IpNetwork::from_str("10.2.2.1/24").unwrap(); + assert!(subnet_2.contains(ips[1].ip)); + let subnet_3 = IpNetwork::from_str("10.3.3.1/24").unwrap(); + assert!(subnet_3.contains(ips[2].ip)); + + // IP availability validation + let ip_check = json!({ + "ips": ["10.1.1.2".to_string(), "10.2.2.2".to_string(), "10.3.3.2".to_string()], + }); + let response = client + .post(format!("/api/v1/device/network/ip/{location_id}")) + .json(&ip_check) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let res = response.json::>().await; + assert_eq!(res.len(), 3); + assert_eq!( + res, + [ + IpCheckRes { + available: true, + valid: true + }, + IpCheckRes { + available: true, + valid: true + }, + IpCheckRes { + available: true, + valid: true + } + ] + ); + + let ip_check = json!({ + "ips": ["10.11.1.2".to_string(), "10.2.2.2".to_string(), "10.3.3.1".to_string()], + }); + let response = client + .post(format!("/api/v1/device/network/ip/{location_id}")) + .json(&ip_check) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let res = response.json::>().await; + assert_eq!(res.len(), 3); + assert_eq!( + res, + [ + IpCheckRes { + available: false, + valid: false + }, + IpCheckRes { + available: true, + valid: true + }, + IpCheckRes { + available: false, + valid: true, + } + ] + ) +} diff --git a/crates/defguard_core/tests/integration/wireguard_network_import.rs b/crates/defguard_core/tests/integration/api/wireguard_network_import.rs similarity index 99% rename from crates/defguard_core/tests/integration/wireguard_network_import.rs rename to crates/defguard_core/tests/integration/api/wireguard_network_import.rs index bef4ba9727..aad4c8d62c 100644 --- a/crates/defguard_core/tests/integration/wireguard_network_import.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_import.rs @@ -18,7 +18,7 @@ use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::sync::broadcast::error::TryRecvError; -use crate::common::{fetch_user_details, make_test_client, setup_pool}; +use super::common::{fetch_user_details, make_test_client, setup_pool}; #[sqlx::test] async fn test_config_import(_: PgPoolOptions, options: PgConnectOptions) { @@ -62,8 +62,7 @@ async fn test_config_import(_: PgPoolOptions, options: PgConnectOptions) { false, false, LocationMfaMode::Disabled, - ) - .unwrap(); + ); initial_network.save(&pool).await.unwrap(); // add existing devices diff --git a/crates/defguard_core/tests/integration/wireguard_network_stats.rs b/crates/defguard_core/tests/integration/api/wireguard_network_stats.rs similarity index 99% rename from crates/defguard_core/tests/integration/wireguard_network_stats.rs rename to crates/defguard_core/tests/integration/api/wireguard_network_stats.rs index 5efb4b5feb..8a02b5c994 100644 --- a/crates/defguard_core/tests/integration/wireguard_network_stats.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_stats.rs @@ -18,7 +18,7 @@ use serde::Deserialize; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_network, make_test_client, setup_pool}; +use super::common::{make_network, make_test_client, setup_pool}; static DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:00Z"; diff --git a/crates/defguard_core/tests/integration/worker.rs b/crates/defguard_core/tests/integration/api/worker.rs similarity index 99% rename from crates/defguard_core/tests/integration/worker.rs rename to crates/defguard_core/tests/integration/api/worker.rs index 7243311ff6..88833a892d 100644 --- a/crates/defguard_core/tests/integration/worker.rs +++ b/crates/defguard_core/tests/integration/api/worker.rs @@ -8,7 +8,7 @@ use defguard_core::{ use reqwest::StatusCode; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_client_with_state, setup_pool}; +use super::common::{make_client_with_state, setup_pool}; #[sqlx::test] async fn test_scheduling_worker_jobs(_: PgPoolOptions, options: PgConnectOptions) { diff --git a/crates/defguard_core/tests/integration/common.rs b/crates/defguard_core/tests/integration/common.rs new file mode 100644 index 0000000000..d89859fc9c --- /dev/null +++ b/crates/defguard_core/tests/integration/common.rs @@ -0,0 +1,34 @@ +use std::str::FromStr; + +use defguard_core::{SERVER_CONFIG, config::DefGuardConfig, db::User}; +use reqwest::Url; +use secrecy::ExposeSecret; +use sqlx::PgPool; + +/// Allows overriding the default DefGuard URL for tests, as during the tests, the server has a random port, making the URL unpredictable beforehand. +// TODO: Allow customizing the whole config, not just the URL +pub(crate) fn init_config(custom_defguard_url: Option<&str>) -> DefGuardConfig { + let url = custom_defguard_url.unwrap_or("http://localhost:8000"); + let mut config = DefGuardConfig::new_test_config(); + config.url = Url::from_str(url).unwrap(); + let _ = SERVER_CONFIG.set(config.clone()); + config +} + +pub(crate) async fn initialize_users(pool: &PgPool, config: &DefGuardConfig) { + User::init_admin_user(pool, config.default_admin_password.expose_secret()) + .await + .unwrap(); + + User::new( + "hpotter", + Some("pass123"), + "Potter", + "Harry", + "h.potter@hogwart.edu.uk", + None, + ) + .save(pool) + .await + .unwrap(); +} diff --git a/crates/defguard_core/tests/integration/grpc/common/mock_gateway.rs b/crates/defguard_core/tests/integration/grpc/common/mock_gateway.rs new file mode 100644 index 0000000000..5622c399fb --- /dev/null +++ b/crates/defguard_core/tests/integration/grpc/common/mock_gateway.rs @@ -0,0 +1,161 @@ +use std::time::Duration; + +use defguard_core::grpc::{ + AUTHORIZATION_HEADER, HOSTNAME_HEADER, + proto::gateway::{ + Configuration, ConfigurationRequest, StatsUpdate, Update, + gateway_service_client::GatewayServiceClient, + }, +}; +use defguard_version::{Version, client::ClientVersionInterceptor}; +use tokio::{ + sync::mpsc::{UnboundedSender, unbounded_channel}, + task::JoinHandle, + time::timeout, +}; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tonic::{ + Request, Response, Status, Streaming, + metadata::MetadataValue, + service::{Interceptor, InterceptorLayer, interceptor::InterceptedService}, + transport::Channel, +}; +use tower::ServiceBuilder; + +pub(crate) struct MockGateway { + client: GatewayServiceClient< + InterceptedService, ClientVersionInterceptor>, + >, + hostname: Option, + stats_update_thread_handle: Option>, + updates_stream: Option>, +} + +impl Drop for MockGateway { + fn drop(&mut self) { + if let Some(handle) = &self.stats_update_thread_handle { + handle.abort(); + } + } +} + +#[derive(Clone)] +struct AuthInterceptor { + auth_token: Option, + hostname: Option, +} + +impl AuthInterceptor { + pub(crate) fn new(auth_token: Option, hostname: Option) -> Self { + Self { + auth_token, + hostname, + } + } +} + +impl Interceptor for AuthInterceptor { + fn call(&mut self, mut request: tonic::Request<()>) -> Result, Status> { + // add authorization token + if let Some(token) = &self.auth_token { + request.metadata_mut().insert( + AUTHORIZATION_HEADER, + MetadataValue::try_from(token).expect("failed to convert token into metadata"), + ); + }; + + // add gateway hostname + if let Some(hostname) = &self.hostname { + request.metadata_mut().insert( + HOSTNAME_HEADER, + MetadataValue::try_from(hostname) + .expect("failed to convert hostname into metadata"), + ); + }; + + Ok(request) + } +} + +impl MockGateway { + #[must_use] + pub(crate) async fn new( + client_channel: Channel, + version: Version, + auth_token: Option, + hostname: Option, + ) -> Self { + let intercepted_channel = ServiceBuilder::new() + .layer(InterceptorLayer::new(ClientVersionInterceptor::new( + version, + ))) + .layer(InterceptorLayer::new(AuthInterceptor::new( + auth_token, + hostname.clone(), + ))) + .service(client_channel); + + let client = GatewayServiceClient::new(intercepted_channel); + + Self { + client, + hostname, + stats_update_thread_handle: None, + updates_stream: None, + } + } + + // Fetch gateway config from core + pub(crate) async fn get_gateway_config(&mut self) -> Result, Status> { + let request = Request::new(ConfigurationRequest { + name: self.hostname.clone(), + }); + + self.client.config(request).await + } + + pub(crate) async fn connect_to_updates_stream(&mut self) { + let request = Request::new(()); + + let updates_stream = self.client.updates(request).await.unwrap().into_inner(); + + self.updates_stream = Some(updates_stream); + } + + pub(crate) fn disconnect_from_updates_stream(&mut self) { + self.updates_stream = None; + } + + #[must_use] + pub(crate) async fn receive_next_update(&mut self) -> Option { + match &mut self.updates_stream { + Some(stream) => match timeout(Duration::from_millis(100), stream.message()).await { + Ok(result) => result.expect("failed to reveive update message"), + Err(_) => None, + }, + None => None, + } + } + + // Connect to interface stats update endpoint + // and return a tx which can be used to send stats updates to test gRPC server + #[must_use] + pub(crate) async fn setup_stats_update_stream(&mut self) -> UnboundedSender { + let (tx, rx) = unbounded_channel(); + + let request = Request::new(UnboundedReceiverStream::new(rx)); + + let mut client = self.client.clone(); + let task_handle = tokio::spawn(async move { + client.stats(request).await.expect("stats stream closed"); + }); + + self.stats_update_thread_handle = Some(task_handle); + + tx + } + + pub(crate) fn hostname(&self) -> String { + self.hostname.clone().unwrap_or_default() + } +} diff --git a/crates/defguard_core/tests/integration/grpc/common/mod.rs b/crates/defguard_core/tests/integration/grpc/common/mod.rs new file mode 100644 index 0000000000..ae5bb98997 --- /dev/null +++ b/crates/defguard_core/tests/integration/grpc/common/mod.rs @@ -0,0 +1,179 @@ +use std::sync::{Arc, Mutex}; + +use axum::http::Uri; +use defguard_core::{ + auth::failed_login::FailedLoginMap, + db::{AppEvent, GatewayEvent, models::settings::initialize_current_settings}, + enterprise::license::{License, set_cached_license}, + events::GrpcEvent, + grpc::{ + WorkerState, build_grpc_service_router, + gateway::{client_state::ClientMap, map::GatewayMap}, + }, + mail::Mail, +}; +use hyper_util::rt::TokioIo; +use sqlx::PgPool; +use tokio::{ + io::DuplexStream, + sync::{ + broadcast::{self, Sender}, + mpsc::{UnboundedReceiver, unbounded_channel}, + }, + task::JoinHandle, +}; +use tonic::transport::{Channel, Endpoint, Server, server::Router}; +use tower::service_fn; + +use crate::common::{init_config, initialize_users}; + +pub mod mock_gateway; + +pub struct TestGrpcServer { + grpc_server_task_handle: JoinHandle<()>, + pub grpc_event_rx: UnboundedReceiver, + wireguard_tx: Sender, + gateway_state: Arc>, + client_state: Arc>, + pub client_channel: Channel, +} + +impl TestGrpcServer { + #[must_use] + pub async fn new( + server_stream: DuplexStream, + grpc_router: Router, + grpc_event_rx: UnboundedReceiver, + wireguard_tx: Sender, + gateway_state: Arc>, + client_state: Arc>, + client_channel: Channel, + ) -> Self { + // spawn test gRPC server + let grpc_server_task_handle = tokio::spawn(async move { + grpc_router + .serve_with_incoming(tokio_stream::once(Ok::<_, std::io::Error>(server_stream))) + .await + .map_err(|err| eprintln!("Unexpected test gRPC server error: {err}")) + .unwrap() + }); + + Self { + grpc_server_task_handle, + grpc_event_rx, + wireguard_tx, + gateway_state, + client_state, + client_channel, + } + } + + pub fn get_gateway_map(&self) -> std::sync::MutexGuard<'_, GatewayMap> { + self.gateway_state + .lock() + .expect("failed to acquire lock on gateway state") + } + + pub fn get_client_map(&self) -> std::sync::MutexGuard<'_, ClientMap> { + self.client_state + .lock() + .expect("failed to acquire lock on client state") + } + + pub fn send_wireguard_event(&self, event: GatewayEvent) { + self.wireguard_tx + .send(event) + .expect("failed to send gateway event"); + } +} + +impl Drop for TestGrpcServer { + fn drop(&mut self) { + // explicitly stop spawned gRPC server task + self.grpc_server_task_handle.abort(); + } +} + +pub(crate) async fn create_client_channel(client_stream: DuplexStream) -> Channel { + // Move client to an option so we can _move_ the inner value + // on the first attempt to connect. All other attempts will fail. + // reference: https://github.com/hyperium/tonic/blob/master/examples/src/mock/mock.rs#L31 + let mut client = Some(client_stream); + Endpoint::try_from("http://[::]:50051") + .expect("Failed to create channel") + .connect_with_connector(service_fn(move |_: Uri| { + let client = client.take(); + + async move { + if let Some(client) = client { + Ok(TokioIo::new(client)) + } else { + Err(std::io::Error::other("Client already taken")) + } + } + })) + .await + .expect("Failed to create client channel") +} + +pub(crate) async fn make_grpc_test_server(pool: &PgPool) -> TestGrpcServer { + // create communication channel for clients + let (client_stream, server_stream) = tokio::io::duplex(1024); + let client_channel = create_client_channel(client_stream).await; + + // setup helper structs + let (grpc_event_tx, grpc_event_rx) = unbounded_channel::(); + let (app_event_tx, _app_event_rx) = unbounded_channel::(); + let worker_state = Arc::new(Mutex::new(WorkerState::new(app_event_tx.clone()))); + let (wg_tx, _wg_rx) = broadcast::channel::(16); + let (mail_tx, _mail_rx) = unbounded_channel::(); + let gateway_state = Arc::new(Mutex::new(GatewayMap::new())); + let client_state = Arc::new(Mutex::new(ClientMap::new())); + + let failed_logins = FailedLoginMap::new(); + let failed_logins = Arc::new(Mutex::new(failed_logins)); + + let config = init_config(None); + initialize_users(pool, &config).await; + initialize_current_settings(pool) + .await + .expect("Could not initialize settings"); + + let license = License::new( + "test_customer".to_string(), + false, + // Permanent license + None, + None, + None, + ); + + set_cached_license(Some(license)); + let server = Server::builder(); + + let grpc_router = build_grpc_service_router( + server, + pool.clone(), + worker_state, + gateway_state.clone(), + client_state.clone(), + wg_tx.clone(), + mail_tx, + failed_logins, + grpc_event_tx, + Default::default(), + ) + .await + .unwrap(); + + TestGrpcServer::new( + server_stream, + grpc_router, + grpc_event_rx, + wg_tx, + gateway_state, + client_state, + client_channel, + ) + .await +} diff --git a/crates/defguard_core/tests/integration/grpc/gateway.rs b/crates/defguard_core/tests/integration/grpc/gateway.rs new file mode 100644 index 0000000000..d4aba18ee5 --- /dev/null +++ b/crates/defguard_core/tests/integration/grpc/gateway.rs @@ -0,0 +1,559 @@ +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + time::Duration, +}; + +use chrono::{Days, Utc}; +use claims::{assert_err_eq, assert_matches}; +use defguard_core::{ + db::{ + Device, Id, NoId, User, WireguardNetwork, + models::{ + device::DeviceType, wireguard::LocationMfaMode, + wireguard_peer_stats::WireguardPeerStats, + }, + setup_pool, + }, + enterprise::{license::set_cached_license, limits::update_counts}, + events::GrpcEvent, + grpc::{ + MIN_GATEWAY_VERSION, + gateway::{Configuration, Update, update}, + proto::{ + enterprise::firewall::FirewallPolicy, + gateway::{PeerStats, StatsUpdate, stats_update::Payload}, + }, + }, +}; +use semver::Version; +use sqlx::{ + PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, +}; +use tokio::{sync::mpsc::error::TryRecvError, time::sleep}; +use tonic::Code; + +use crate::grpc::common::{TestGrpcServer, make_grpc_test_server, mock_gateway::MockGateway}; + +async fn setup_test_server( + pool: PgPool, +) -> (TestGrpcServer, MockGateway, WireguardNetwork, User) { + let test_server = make_grpc_test_server(&pool).await; + + // create a test location + let location = WireguardNetwork::new( + "test location".to_string(), + Vec::new(), + 1000, + "endpoint1".to_string(), + None, + Vec::new(), + 100, + 100, + false, + false, + LocationMfaMode::Disabled, + ) + .save(&pool) + .await + .unwrap(); + + // set auth token for gateway + let token = location + .generate_gateway_token() + .expect("failed to generate gateway token"); + + // setup mock gateway + let gateway = MockGateway::new( + test_server.client_channel.clone(), + MIN_GATEWAY_VERSION, + Some(token), + Some("test gateway".into()), + ) + .await; + + // get test user + let test_user = User::find_by_username(&pool, "hpotter") + .await + .unwrap() + .unwrap(); + + (test_server, gateway, location, test_user) +} + +#[sqlx::test] +async fn test_gateway_authorization(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (test_server, _gateway, test_location, _test_user) = setup_test_server(pool).await; + + // setup another test gateway without a token + let mut test_gateway = MockGateway::new( + test_server.client_channel.clone(), + MIN_GATEWAY_VERSION, + None, + Some("test gateway".into()), + ) + .await; + + // make a request without auth token + let response = test_gateway.get_gateway_config().await; + + // check that response code is `Code::Unauthenticated` + assert!(response.is_err()); + let status = response.err().unwrap(); + assert_eq!(status.code(), Code::Unauthenticated); + + // setup another test gateway with an invalid token + let mut test_gateway = MockGateway::new( + test_server.client_channel.clone(), + MIN_GATEWAY_VERSION, + Some("invalid_token".into()), + Some("test gateway".into()), + ) + .await; + let response = test_gateway.get_gateway_config().await; + assert!(response.is_err()); + let status = response.err().unwrap(); + assert_eq!(status.code(), Code::Unauthenticated); + + // use valid token and retry + let token = test_location.generate_gateway_token().unwrap(); + // setup another test gateway without a token + let mut test_gateway = MockGateway::new( + test_server.client_channel.clone(), + MIN_GATEWAY_VERSION, + Some(token), + Some("test gateway".into()), + ) + .await; + let response = test_gateway.get_gateway_config().await; + assert!(response.is_ok()); +} + +#[sqlx::test] +async fn test_gateway_hostname_is_required(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (test_server, _gateway, test_location, _test_user) = setup_test_server(pool).await; + + // setup gateway without hostname + let token = test_location.generate_gateway_token().unwrap(); + let mut test_gateway = MockGateway::new( + test_server.client_channel.clone(), + MIN_GATEWAY_VERSION, + Some(token.clone()), + None, + ) + .await; + + // make a request without hostname + let response = test_gateway.get_gateway_config().await; + + // check that response code is `Code::Internal` + assert!(response.is_err()); + let status = response.err().unwrap(); + assert_eq!(status.code(), Code::Internal); + + // set hostname and retry + let mut test_gateway = MockGateway::new( + test_server.client_channel.clone(), + MIN_GATEWAY_VERSION, + Some(token), + Some("test gateway".into()), + ) + .await; + let response = test_gateway.get_gateway_config().await; + assert!(response.is_ok()); +} + +#[sqlx::test] +async fn test_gateway_status(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (test_server, mut gateway, test_location, _test_user) = setup_test_server(pool).await; + + // initial gateway map is empty + { + let gateway_map = test_server.get_gateway_map(); + assert!(gateway_map.is_empty()) + } + + // gateway request initial config + // it should be added to status map as disconnected + let response = gateway.get_gateway_config().await; + assert!(response.is_ok()); + { + let gateway_map = test_server.get_gateway_map(); + let location_gateways = gateway_map.get_network_gateway_status(test_location.id); + assert_eq!(location_gateways.len(), 1); + let gateway_state = location_gateways.first().unwrap(); + assert!(!gateway_state.connected); + assert!(gateway_state.connected_at.is_none()); + assert!(gateway_state.disconnected_at.is_none()); + assert_eq!(gateway_state.hostname, gateway.hostname()); + } + + // gateway connects to updates stream + // it should be marked as connected + gateway.connect_to_updates_stream().await; + { + let gateway_map = test_server.get_gateway_map(); + let location_gateways = gateway_map.get_network_gateway_status(test_location.id); + assert_eq!(location_gateways.len(), 1); + let gateway_state = location_gateways.first().unwrap(); + assert!(gateway_state.connected); + assert!(gateway_state.connected_at.is_some()); + assert!(gateway_state.disconnected_at.is_none()); + assert_eq!(gateway_state.hostname, gateway.hostname()); + } + + // gateway disconnect from updates stream + // it should be marked as disconnected + gateway.disconnect_from_updates_stream(); + // wait for the background thread to handle the disconnect + sleep(Duration::from_millis(100)).await; + + { + let gateway_map = test_server.get_gateway_map(); + let location_gateways = gateway_map.get_network_gateway_status(test_location.id); + assert_eq!(location_gateways.len(), 1); + let gateway_state = location_gateways.first().unwrap(); + assert!(!gateway_state.connected); + assert!(gateway_state.connected_at.is_some()); + assert!(gateway_state.disconnected_at.is_some()); + assert_eq!(gateway_state.hostname, gateway.hostname()); + } +} + +#[sqlx::test] +async fn test_vpn_client_connected(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut test_server, mut gateway, test_location, test_user) = + setup_test_server(pool.clone()).await; + + // initial client map is empty + { + let client_map = test_server.get_client_map(); + assert!(client_map.is_empty()) + } + + // connect stats stream + let stats_tx = gateway.setup_stats_update_stream().await; + let mut update_id = 1; + + // add user device + let device_pubkey = "wYOt6ImBaQ3BEMQ3Xf5P5fTnbqwOvjcqYkkSBt+1xOg="; + let test_device = Device::new( + "test device".into(), + device_pubkey.into(), + test_user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + // send stats update for existing device with old handshake + // and verify no gRPC event is emitted + stats_tx + .send(StatsUpdate { + id: update_id, + payload: Some(Payload::PeerStats(PeerStats { + public_key: device_pubkey.into(), + endpoint: "1.2.3.4:1234".into(), + latest_handshake: 0, + ..Default::default() + })), + }) + .expect("failed to send stats update"); + + assert_err_eq!(test_server.grpc_event_rx.try_recv(), TryRecvError::Empty); + + // send stats update with current handshake + update_id += 1; + stats_tx + .send(StatsUpdate { + id: update_id, + payload: Some(Payload::PeerStats(PeerStats { + public_key: device_pubkey.into(), + endpoint: "1.2.3.4:1234".into(), + latest_handshake: Utc::now().timestamp() as u64, + ..Default::default() + })), + }) + .expect("failed to send stats update"); + + // wait for event to be emitted + sleep(Duration::from_millis(100)).await; + let grpc_event = test_server + .grpc_event_rx + .try_recv() + .expect("failed to receive gRPC event"); + + assert_matches!( + grpc_event, + GrpcEvent::ClientConnected { + context: _, + location, + device + } if ((location.id == test_location.id) & (device.id == test_device.id)) + ); +} + +#[sqlx::test] +async fn test_vpn_client_disconnected(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (mut test_server, mut gateway, test_location, test_user) = + setup_test_server(pool.clone()).await; + + // add user device + let device_pubkey = "wYOt6ImBaQ3BEMQ3Xf5P5fTnbqwOvjcqYkkSBt+1xOg="; + let test_device = Device::new( + "test device".into(), + device_pubkey.into(), + test_user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + // insert device into client map with an old handshake + { + let mut client_map = test_server.get_client_map(); + let now = Utc::now().naive_utc(); + let stats = WireguardPeerStats { + id: NoId, + device_id: test_device.id, + collected_at: now, + network: test_location.id, + endpoint: None, + upload: 0, + download: 0, + latest_handshake: now.checked_sub_days(Days::new(1)).unwrap(), + allowed_ips: None, + }; + client_map + .connect_vpn_client( + test_location.id, + &gateway.hostname(), + device_pubkey, + &test_device, + &test_user, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080), + &stats, + ) + .expect("failed to insert connected client"); + } + + // connect stats stream + let stats_tx = gateway.setup_stats_update_stream().await; + let mut update_id = 1; + + // send stats update with old handshake + update_id += 1; + stats_tx + .send(StatsUpdate { + id: update_id, + payload: Some(Payload::PeerStats(PeerStats { + public_key: device_pubkey.into(), + endpoint: "1.2.3.4:1234".into(), + latest_handshake: 0, + ..Default::default() + })), + }) + .expect("failed to send stats update"); + + // wait for event to be emitted + sleep(Duration::from_millis(100)).await; + let grpc_event = test_server + .grpc_event_rx + .try_recv() + .expect("failed to receive gRPC event"); + + assert_matches!( + grpc_event, + GrpcEvent::ClientDisconnected { + context: _, + location, + device + } if ((location.id == test_location.id) & (device.id == test_device.id)) + ); +} + +#[sqlx::test] +async fn test_gateway_update_routing(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (test_server, mut gateway_1, test_location, _test_user) = + setup_test_server(pool.clone()).await; + + // setup another test location & gateway + let test_location_2 = WireguardNetwork::new( + "test location 2".to_string(), + Vec::new(), + 1000, + "endpoint2".to_string(), + None, + Vec::new(), + 100, + 100, + false, + false, + LocationMfaMode::Disabled, + ) + .save(&pool) + .await + .unwrap(); + + // set auth token for gateway + let token = test_location_2 + .generate_gateway_token() + .expect("failed to generate gateway token"); + let mut gateway_2 = MockGateway::new( + test_server.client_channel.clone(), + MIN_GATEWAY_VERSION, + Some(token), + Some("test_gateway_2".into()), + ) + .await; + + // register gateways with core + let _config_1 = gateway_1.get_gateway_config().await; + let _config_2 = gateway_2.get_gateway_config().await; + + // connect gateways to the updates stream + gateway_1.connect_to_updates_stream().await; + gateway_2.connect_to_updates_stream().await; + + // send update for location 1 + test_server.send_wireguard_event(defguard_core::db::GatewayEvent::NetworkDeleted( + test_location.id, + "network name".into(), + )); + + // only one gateway should receive this update + assert!(gateway_2.receive_next_update().await.is_none()); + let update = gateway_1.receive_next_update().await.unwrap(); + let expected_update = Update { + update_type: 2, + update: Some(update::Update::Network(Configuration { + name: "network name".into(), + prvkey: String::new(), + addresses: Vec::new(), + port: 0, + peers: Vec::new(), + firewall_config: None, + })), + }; + assert_eq!(update, expected_update); + + // send update for location 2 + test_server.send_wireguard_event(defguard_core::db::GatewayEvent::NetworkDeleted( + test_location_2.id, + "network name 2".into(), + )); + + // only one gateway should receive this update + assert!(gateway_1.receive_next_update().await.is_none()); + let update = gateway_2.receive_next_update().await.unwrap(); + let expected_update = Update { + update_type: 2, + update: Some(update::Update::Network(Configuration { + name: "network name 2".into(), + prvkey: String::new(), + addresses: Vec::new(), + port: 0, + peers: Vec::new(), + firewall_config: None, + })), + }; + assert_eq!(update, expected_update); + + // send update for location which does not exist + test_server.send_wireguard_event(defguard_core::db::GatewayEvent::NetworkDeleted( + 1234, + "does not exist".into(), + )); + + // no gateway should receive this update + assert!(gateway_1.receive_next_update().await.is_none()); + assert!(gateway_2.receive_next_update().await.is_none()); +} + +#[sqlx::test] +async fn test_gateway_config(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (_test_server, mut gateway, mut test_location, _test_user) = + setup_test_server(pool.clone()).await; + + // get gateway config + let config = gateway.get_gateway_config().await.unwrap().into_inner(); + + assert_eq!(config.name, test_location.name); + assert!(config.firewall_config.is_none()); + + // enable ACL for test location + test_location.acl_enabled = true; + test_location + .save(&pool) + .await + .expect("failed to update location"); + + // get gateway config + let config = gateway.get_gateway_config().await.unwrap().into_inner(); + assert!(config.firewall_config.is_some()); + assert_eq!( + config.firewall_config.unwrap().default_policy == i32::from(FirewallPolicy::Allow), + test_location.acl_default_allow + ); + + // unset the license and create another location to exceed limits and disable enterprise features + set_cached_license(None); + let _test_location_2 = WireguardNetwork::new( + "test location 2".to_string(), + Vec::new(), + 1000, + "endpoint2".to_string(), + None, + Vec::new(), + 100, + 100, + false, + false, + LocationMfaMode::Disabled, + ) + .save(&pool) + .await + .unwrap(); + update_counts(&pool).await.unwrap(); + + let config = gateway.get_gateway_config().await.unwrap().into_inner(); + assert!(config.firewall_config.is_none()); +} + +#[sqlx::test] +async fn test_gateway_version_validation(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let (test_server, _gateway, test_location, _test_user) = setup_test_server(pool.clone()).await; + + // setup gateway with unsupported version + let unsupported_version = + Version::new(MIN_GATEWAY_VERSION.major, MIN_GATEWAY_VERSION.minor - 1, 0); + let token = test_location.generate_gateway_token().unwrap(); + // setup another test gateway without a token + let mut test_gateway = MockGateway::new( + test_server.client_channel.clone(), + unsupported_version, + Some(token), + Some("test gateway".into()), + ) + .await; + let response = test_gateway.get_gateway_config().await; + + // check that response code is `Code::FailedPrecondition` + assert!(response.is_err()); + let status = response.err().unwrap(); + assert_eq!(status.code(), Code::FailedPrecondition); +} diff --git a/crates/defguard_core/tests/integration/grpc/mod.rs b/crates/defguard_core/tests/integration/grpc/mod.rs new file mode 100644 index 0000000000..5b53a1b0d6 --- /dev/null +++ b/crates/defguard_core/tests/integration/grpc/mod.rs @@ -0,0 +1,2 @@ +mod common; +mod gateway; diff --git a/crates/defguard_core/tests/integration/main.rs b/crates/defguard_core/tests/integration/main.rs index 4891501678..f85d8d0fa3 100644 --- a/crates/defguard_core/tests/integration/main.rs +++ b/crates/defguard_core/tests/integration/main.rs @@ -1,21 +1,3 @@ -mod acl; -mod api_tokens; -mod auth; +mod api; mod common; -mod enrollment; -mod enterprise_settings; -mod forward_auth; -mod group; -mod oauth; -mod openid; -mod openid_login; -mod settings; -mod snat; -mod user; -mod webhook; -mod wireguard; -mod wireguard_network_allowed_groups; -mod wireguard_network_devices; -mod wireguard_network_import; -mod wireguard_network_stats; -mod worker; +mod grpc; diff --git a/crates/defguard_core/user_agent_header_regexes.yaml b/crates/defguard_core/user_agent_header_regexes.yaml index 89d98ccb63..bdef390459 100644 --- a/crates/defguard_core/user_agent_header_regexes.yaml +++ b/crates/defguard_core/user_agent_header_regexes.yaml @@ -91,7 +91,7 @@ user_agent_parsers: Jeeves|[Bb]ai[Dd]u[Ss]pider(?:-[A-Za-z]{1,30})(?:-[A-Za-z]{1,30}|)|bingbot|BingPreview|blitzbot|BlogBridge|Bloglovin|BoardReader Blog Indexer|BoardReader Favicon Fetcher|boitho.com-dc|BotSeer|BUbiNG|\b\w{0,30}favicon\w{0,30}\b|\bYeti(?:-[a-z]{1,30}|)|Catchpoint(?: bot|)|[Cc]harlotte|Checklinks|clumboot|Comodo HTTP\(S\) Crawler|Comodo-Webinspector-Crawler|ConveraCrawler|CRAWL-E|CrawlConvera|Daumoa(?:-feedfetcher|)|Feed - Seeker Bot|Feedbin|findlinks|Flamingo_SearchEngine|FollowSite Bot|furlbot|Genieo|gigabot|GomezAgent|gonzo1|(?:[a-zA-Z]{1,30}-|)Googlebot(?:-[a-zA-Z]{1,30}|)|Google + Seeker Bot|Feedbin|findlinks|Flamingo_SearchEngine|FollowSite Bot|furlbot|Genieo|gigabot|GomezAgent|gonzo1|(?:[a-zA-Z]{1,30}-|)Googlebot(?:-[a-zA-Z]{1,30}|)|GoogleOther|Google SketchUp|grub-client|gsa-crawler|heritrix|HiddenMarket|holmes|HooWWWer|htdig|ia_archiver|ICC-Crawler|Icarus6j|ichiro(?:/mobile|)|IconSurf|IlTrovatore(?:-Setaccio|)|InfuzApp|Innovazion Crawler|InternetArchive|IP2[a-z]{1,30}Bot|jbot\b|KaloogaBot|Kraken|Kurzor|larbin|LEIA|LesnikBot|Linguee Bot|LinkAider|LinkedInBot|Lite Bot|Llaut|lycos|Mail\.RU_Bot|masscan|masidani_bot|Mediapartners-Google|Microsoft @@ -102,7 +102,7 @@ user_agent_parsers: Spider|Squrl Java|Stringer|TheUsefulbot|ThumbShotsBot|Thumbshots\.ru|Tiny Tiny RSS|Twitterbot|WhatsApp|URL2PNG|Vagabondo|VoilaBot|^vortex|Votay bot|^voyager|WASALive.Bot|Web-sniffer|WebThumb|WeSEE:[A-z]{1,30}|WhatWeb|WIRE|WordPress|Wotbox|www\.almaden\.ibm\.com|Xenu(?:.s|) Link Sleuth|Xerka [A-z]{1,30}Bot|yacy(?:bot|)|YahooSeeker|Yahoo! Slurp|Yandex\w{1,30}|YodaoBot(?:-[A-z]{1,30}|)|YottaaMonitor|Yowedo|^Zao|^Zao-Crawler|ZeBot_www\.ze\.bz|ZooShot|ZyBorg|ArcGIS - Hub Indexer)(?:[ /]v?(\d+)(?:\.(\d+)(?:\.(\d+)|)|)|)' + Hub Indexer|GPTBot|Google-InspectionTool)(?:[ /]v?(\d+)(?:\.(\d+)(?:\.(\d+)|)|)|)' - regex: \b(Boto3?|JetS3t|aws-(?:cli|sdk-(?:cpp|go|go-v\d|java|nodejs|ruby2?|dotnet-(?:\d{1,2}|core)))|s3fs)/(\d+)\.(\d+)(?:\.(\d+)|) - regex: (FME)\/(\d+\.\d+)\.(\d+)\.(\d+) - regex: (QGIS)\/(\d)\.?0?(\d{1,2})\.?0?(\d{1,2}) @@ -140,6 +140,8 @@ user_agent_parsers: family_replacement: TikTok - regex: (BytedanceWebview)\/[a-z0-9]+ family_replacement: TikTok + - regex: Mozilla.{1,200}Mobile.{1,100}(KAKAOTALK)/(\d+)\.(\d+)\.(\d+) + family_replacement: KakaoTalk - regex: Mozilla.{1,200}Mobile.{1,100}(Phantom\/ios|Phantom\/android).(\d+)\.(\d+)\.(\d+) family_replacement: Phantom - regex: Mozilla.{1,100}Mobile.{1,100}(AspiegelBot|PetalBot) @@ -249,7 +251,7 @@ user_agent_parsers: - regex: (AvastSecureBrowser|Avast)/(\d+)\.(\d+)\.(\d+) family_replacement: Avast Secure Browser - regex: (Instabridge)/(\d+)(?:\.(\d+)|)(?:\.(\d+)|) - - regex: (AlohaBrowser)/(\d+)\.(\d+)\.(\d+)(?:\.(\d+)|) + - regex: (AlohaBrowser|ABB)/(\d+)\.(\d+)\.(\d+)(?:\.(\d+)|) family_replacement: Aloha Browser - regex: ((?:B|b)rave(?:\sChrome)?)/(\d+)(?:\.(\d+)|)(?:\.(\d+)|)(?:\.(\d+)|) family_replacement: Brave @@ -260,7 +262,7 @@ user_agent_parsers: family_replacement: Edge Mobile - regex: (EdgiOS|EdgA)/(\d+)(?:\.(\d+)|)(?:\.(\d+)|)(?:\.(\d+)|) family_replacement: Edge Mobile - - regex: (OculusBrowser)/(\d+)\.(\d+).0.0(?:\.([0-9\-]+)|) + - regex: (OculusBrowser)/(\d+)\.(\d+)(?:\.([0-9\-]+)|) family_replacement: Oculus Browser - regex: (SamsungBrowser)/(\d+)\.(\d+) family_replacement: Samsung Internet @@ -268,10 +270,6 @@ user_agent_parsers: family_replacement: Seznam prohlížeč - regex: (coc_coc_browser)/(\d+)\.(\d+)(?:\.(\d+)|) family_replacement: Coc Coc - - regex: (baidubrowser)[/\s](\d+)(?:\.(\d+)|)(?:\.(\d+)|) - family_replacement: Baidu Browser - - regex: (FlyFlow)/(\d+)\.(\d+) - family_replacement: Baidu Explorer - regex: (MxBrowser)/(\d+)\.(\d+)(?:\.(\d+)|) family_replacement: Maxthon - regex: (Crosswalk)/(\d+)\.(\d+)\.(\d+)\.(\d+) @@ -285,6 +283,10 @@ user_agent_parsers: family_replacement: TopBuzz - regex: Mozilla.{1,200}Android.{1,200}(GSA)/(\d+)\.(\d+)\.(\d+) family_replacement: Google + - regex: (baidubrowser)[/\s](\d+)(?:\.(\d+)|)(?:\.(\d+)|) + family_replacement: Baidu Browser + - regex: (FlyFlow|flyflow|baiduboxapp)/(\d+)\.(\d+)(?:\.(\d+)|)(?:\.(\d+)|) + family_replacement: Baidu Explorer - regex: (MQQBrowser/Mini)(?:(\d+)(?:\.(\d+)|)(?:\.(\d+)|)|) family_replacement: QQ Browser Mini - regex: (MQQBrowser)(?:/(\d+)(?:\.(\d+)|)(?:\.(\d+)|)|) @@ -305,8 +307,12 @@ user_agent_parsers: family_replacement: Ecosia iOS - regex: (Ecosia) android@(\d+)(?:\.(\d+)|)(?:\.(\d+)|)(?:\.(\d+)|) family_replacement: Ecosia Android - - regex: (VivoBrowser)\/(\d+)\.(\d+)\.(\d+)\.(\d+) + - regex: (VivoBrowser)\/(\d+)\.(\d+)\.(\d+)(?:\.(\d+)|) - regex: (HiBrowser)\/v(\d+)\.(\d+)\.(\d+)\.(\d+) + - regex: (weibo)__(\d+)\.(\d+)\.(\d+) + family_replacement: Weibo + - regex: (WeiboliteiOS|WeiboIntliOS) + family_replacement: Weibo - regex: Version/.{1,300}(Chrome)/(\d+)\.(\d+)\.(\d+)\.(\d+) family_replacement: Chrome Mobile WebView - regex: ; wv\).{1,300}(Chrome)/(\d+)\.(\d+)\.(\d+)\.(\d+) @@ -347,6 +353,48 @@ user_agent_parsers: - regex: PAN (GlobalProtect)/(\d+)\.(\d+)\.(\d+) .{1,100} \(X11; Linux x86_64\) - regex: ^(surveyon)/(\d+)\.(\d+)\.(\d+) family_replacement: Surveyon + - regex: (115Browser)/(\d+)\.(\d+)\.(\d+)\.(\d+) + family_replacement: 115 Browser + - regex: (Avira)/(\d+)\.(\d+)\.(\d+)\.(\d+) + family_replacement: Avira + - regex: (CCleaner)/(\d+)\.(\d+)\.(\d+)\.(\d+) + family_replacement: CCleaner + - regex: (Norton)/(\d+)\.(\d+)\.(\d+)\.(\d+) + family_replacement: Norton + - regex: (Quark)/(\d+)\.(\d+)\.(\d+) + family_replacement: Quark + - regex: (QuarkPC)/(\d+)\.(\d+)\.(\d+) + family_replacement: Quark PC + - regex: (SLBrowser)/(\d+)\.(\d+)\.(\d+)\.(\d+) SLBChan/(\d+) + family_replacement: Smart Lenovo Browser + - regex: (Atom)/(\d+)\.(\d+)\.(\d+)\.(\d+) + family_replacement: Atom Browser + - regex: (Chrome)/\d+\.\d+\.\d+\.\d+ .* QIHU 360(?:SEi18n|ENT) + family_replacement: 360 Secure Browser + - regex: (Decentr) + family_replacement: Decentr Web3 Browser + - regex: (Sparrow) + family_replacement: Sparrow Browser + - regex: (Chromium GOST) + family_replacement: Chromium GOST Browser + - regex: (AOLShield)/(\d+)\.(\d+)\.(\d+)\.(\d+) + family_replacement: AOL Shield Browser + - regex: (Hola)/(\d+)\.(\d+)\.(\d+) + family_replacement: Hola Browser + - regex: (CravingExplorer)/(\d+)\.(\d+)\.(\d+) + family_replacement: Craving Explorer Browser + - regex: (Talon) + family_replacement: Talon Cyber Security Browser + - regex: (Qaxbrowser) + family_replacement: QAX Browser + - regex: (ADG)/(\d+)\.(\d+)\.(\d+) + family_replacement: AOL Desktop Gold Browser + - regex: (SberBrowser)/(\d+)\.(\d+)\.(\d+)\.(\d+) + family_replacement: Sber Browser + - regex: (JiSu)/(\d+)\.(\d+)\.(\d+) + family_replacement: JiSu Browser + - regex: (Wolvic)/(\d+)\.(\d+)\.(\d+) + family_replacement: Wolvic Browser - regex: (Slack_SSB)/(\d+)\.(\d+)\.(\d+) family_replacement: Slack Desktop Client - regex: (HipChat)/?(\d+|) @@ -1195,8 +1243,9 @@ os_parsers: os_v1_replacement: $1 os_v2_replacement: $2 os_v3_replacement: $3 + - regex: (HarmonyOS)[\s;]+(\d+|)\.?(\d+|)\.?(\d+|) device_parsers: - - regex: ^.{0,100}?(?:(?:iPhone|Windows CE|Windows Phone|Android).{0,300}(?:(?:Bot|Yeti)-Mobile|YRSpider|BingPreview|bots?/\d|(?:bot|spider)\.html)|AdsBot-Google-Mobile.{0,200}iPhone) + - regex: ^.{0,100}?(?:(?:iPhone|Windows CE|Windows Phone|Android).{0,300}(?:(?:Bot|Yeti)-Mobile|YRSpider|BingPreview|bots?/\d|(?:bot|spider)\.html|Google-InspectionTool)|AdsBot-Google-Mobile.{0,200}iPhone) regex_flag: i device_replacement: Spider brand_replacement: Spider @@ -1963,7 +2012,7 @@ device_parsers: device_replacement: HTC $1 brand_replacement: HTC model_replacement: $1 - - regex: ; {0,2}(ADR6200|ADR6400L|ADR6425LVW|Amaze|DesireS?|EndeavorU|Eris|EVO|Evo\d[A-Z]+|HD2|IncredibleS?|Inspire[A-Z0-9]*|Inspire[A-Z0-9]*|Sensation[A-Z0-9]*|Wildfire)[ + - regex: ; {0,2}(ADR6200|ADR6400L|ADR6425LVW|Amaze|DesireS?|EndeavorU|Eris|EVO|Evo\d[A-Z]+|HD2|IncredibleS?|Inspire[A-Z0-9]*|Sensation[A-Z0-9]*|Wildfire)[ _-](.{1,200}?)(?:[/;\)]|Build|MIUI|1\.0) regex_flag: i device_replacement: HTC $1 $2 @@ -3842,7 +3891,7 @@ device_parsers: Java|EtaoSpider|PaperLiBot|SputnikBot|A6\-Indexer|netresearch|searchsight|baiduspider|YisouSpider|ICC\-Crawler|http%20client|Python-urllib|dataparksearch|converacrawler|Screaming Frog|AppEngine-Google|YahooCacheSystem|fast\-webcrawler|Sogou Pic Spider|semanticdiscovery|Innovazion Crawler|facebookexternalhit|Google.{0,200}/\+/web/snippet|Google-HTTP-Java-Client|BlogBridge|IlTrovatore-Setaccio|InternetArchive|GomezAgent|WebThumbnail|heritrix|NewsGator|PagePeeker|Reaper|ZooShot|holmes|NL-Crawler|Pingdom|StatusCake|WhatsApp|masscan|Google - Web Preview|Qwantify|Yeti|OgScrper|RecipeRadar) + Web Preview|Qwantify|Yeti|OgScrper|RecipeRadar|GPTBot|Google-InspectionTool) regex_flag: i device_replacement: Spider brand_replacement: Spider diff --git a/crates/defguard_event_logger/Cargo.toml b/crates/defguard_event_logger/Cargo.toml index cafa1ccf04..871ba70866 100644 --- a/crates/defguard_event_logger/Cargo.toml +++ b/crates/defguard_event_logger/Cargo.toml @@ -12,11 +12,10 @@ rust-version.workspace = true defguard_core = { workspace = true } # external dependencies +bytes = { workspace = true } chrono = { workspace = true } -ipnetwork = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } -bytes = { workspace = true } diff --git a/crates/defguard_event_logger/src/description.rs b/crates/defguard_event_logger/src/description.rs index dfd6de7d0e..1ca6377198 100644 --- a/crates/defguard_event_logger/src/description.rs +++ b/crates/defguard_event_logger/src/description.rs @@ -10,6 +10,7 @@ use crate::message::{DefguardEvent, EnrollmentEvent, VpnEvent}; +#[must_use] pub fn get_defguard_event_description(event: &DefguardEvent) -> Option { match event { DefguardEvent::UserLogin => None, @@ -70,7 +71,7 @@ pub fn get_defguard_event_description(event: &DefguardEvent) -> Option { "disabled" }; description = format!("{description}, status changed to {status_change_text}"); - }; + } Some(description) } DefguardEvent::UserGroupsModified { @@ -259,6 +260,7 @@ pub fn get_defguard_event_description(event: &DefguardEvent) -> Option { } } +#[must_use] pub fn get_vpn_event_description(event: &VpnEvent) -> Option { match event { VpnEvent::ConnectedToMfaLocation { @@ -288,6 +290,7 @@ pub fn get_vpn_event_description(event: &VpnEvent) -> Option { } } +#[must_use] pub fn get_enrollment_event_description(event: &EnrollmentEvent) -> Option { match event { EnrollmentEvent::EnrollmentStarted => Some("User started enrollment process".to_string()), diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index 8b98bb288e..3d04dce4eb 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -1,7 +1,4 @@ use bytes::Bytes; -use defguard_core::db::models::activity_log::metadata::{ - GroupMembersModifiedMetadata, UserGroupsModifiedMetadata, -}; use defguard_core::db::{ NoId, models::activity_log::{ @@ -11,11 +8,12 @@ use defguard_core::db::{ ApiTokenRenamedMetadata, AuthenticationKeyMetadata, AuthenticationKeyRenamedMetadata, ClientConfigurationTokenMetadata, DeviceMetadata, DeviceModifiedMetadata, EnrollmentDeviceAddedMetadata, EnrollmentTokenMetadata, GroupAssignedMetadata, - GroupMetadata, GroupModifiedMetadata, GroupsBulkAssignedMetadata, LoginFailedMetadata, - MfaLoginFailedMetadata, MfaLoginMetadata, MfaSecurityKeyMetadata, - NetworkDeviceMetadata, NetworkDeviceModifiedMetadata, OpenIdAppMetadata, - OpenIdAppModifiedMetadata, OpenIdAppStateChangedMetadata, OpenIdProviderMetadata, - PasswordChangedByAdminMetadata, PasswordResetMetadata, SettingsUpdateMetadata, + GroupMembersModifiedMetadata, GroupMetadata, GroupModifiedMetadata, + GroupsBulkAssignedMetadata, LoginFailedMetadata, MfaLoginFailedMetadata, + MfaLoginMetadata, MfaSecurityKeyMetadata, NetworkDeviceMetadata, + NetworkDeviceModifiedMetadata, OpenIdAppMetadata, OpenIdAppModifiedMetadata, + OpenIdAppStateChangedMetadata, OpenIdProviderMetadata, PasswordChangedByAdminMetadata, + PasswordResetMetadata, SettingsUpdateMetadata, UserGroupsModifiedMetadata, UserMetadata, UserMfaDisabledMetadata, UserModifiedMetadata, UserSnatBindingMetadata, UserSnatBindingModifiedMetadata, VpnClientMetadata, VpnClientMfaFailedMetadata, VpnClientMfaMetadata, VpnLocationMetadata, VpnLocationModifiedMetadata, diff --git a/crates/defguard_event_logger/src/message.rs b/crates/defguard_event_logger/src/message.rs index 9100416fbf..26b4145689 100644 --- a/crates/defguard_event_logger/src/message.rs +++ b/crates/defguard_event_logger/src/message.rs @@ -23,6 +23,7 @@ pub struct EventLoggerMessage { } impl EventLoggerMessage { + #[must_use] pub fn new(context: EventContext, event: LoggerEvent) -> Self { Self { context, event } } @@ -46,6 +47,7 @@ pub struct EventContext { } impl EventContext { + #[must_use] pub fn from_api_context( val: ApiRequestContext, location: Option>, @@ -62,6 +64,7 @@ impl EventContext { } } + #[must_use] pub fn from_bidi_context( val: BidiRequestContext, location: Option>, @@ -78,6 +81,7 @@ impl EventContext { } } + #[must_use] pub fn from_internal_context( val: InternalEventContext, location: Option>, diff --git a/crates/defguard_event_router/src/lib.rs b/crates/defguard_event_router/src/lib.rs index 8ecc8c0c26..335ca17838 100644 --- a/crates/defguard_event_router/src/lib.rs +++ b/crates/defguard_event_router/src/lib.rs @@ -9,24 +9,13 @@ //! //! The event router acts as a central hub for all application events: //! -//! 1. Components (web API, gRPC server etc.) send events to the router via the `event_tx` MPSC channel +//! 1. Components (web API, gRPC server etc.) send events to the router via the `event_tx` +//! MPSC channel. //! 2. The router processes these events and forwards them to the appropriate services: //! - Activity log events go to the event logger service //! - WireGuard events go to the gateway service //! - Mail events go to the mail service //! - etc. -//! -//! # Usage -//! -//! To use the event router, components should send `MainEvent` instances to the -//! event channel. The router will handle routing these events to the appropriate -//! services based on their type. -//! -//! ``` -//! // Example: -//! let event = MainEvent::UserLogin { context: user_context }; -//! event_tx.send(event).await.unwrap(); -//! ``` use std::sync::Arc; @@ -57,6 +46,7 @@ pub struct RouterReceiverSet { } impl RouterReceiverSet { + #[must_use] pub fn new( api: UnboundedReceiver, grpc: UnboundedReceiver, @@ -121,33 +111,21 @@ impl EventRouter { loop { // Receive an event from one of the component event channels let event = tokio::select! { - event = self.receivers.api.recv() => match event { - Some(api_event) => Event::Api(api_event), - None => { - error!("API event channel closed"); - return Err(EventRouterError::ApiEventChannelClosed); - } + event = self.receivers.api.recv() => if let Some(api_event) = event { Event::Api(api_event) } else { + error!("API event channel closed"); + return Err(EventRouterError::ApiEventChannelClosed); }, - event = self.receivers.grpc.recv() => match event { - Some(grpc_event) => Event::Grpc(Box::new(grpc_event)), - None => { - error!("gRPC event channel closed"); - return Err(EventRouterError::GrpcEventChannelClosed); - } + event = self.receivers.grpc.recv() => if let Some(grpc_event) = event { Event::Grpc(Box::new(grpc_event)) } else { + error!("gRPC event channel closed"); + return Err(EventRouterError::GrpcEventChannelClosed); }, - event = self.receivers.bidi.recv() => match event { - Some(bidi_event) => Event::Bidi(bidi_event), - None => { - error!("Bidi gRPC stream event channel closed"); - return Err(EventRouterError::BidiEventChannelClosed); - } + event = self.receivers.bidi.recv() => if let Some(bidi_event) = event { Event::Bidi(bidi_event) } else { + error!("Bidi gRPC stream event channel closed"); + return Err(EventRouterError::BidiEventChannelClosed); }, - event = self.receivers.internal.recv() => match event { - Some(internal_event) => Event::Internal(Box::new(internal_event)), - None => { - error!("Internal event channel closed"); - return Err(EventRouterError::InternalEventChannelClosed); - } + event = self.receivers.internal.recv() => if let Some(internal_event) = event { Event::Internal(Box::new(internal_event)) } else { + error!("Internal event channel closed"); + return Err(EventRouterError::InternalEventChannelClosed); }, }; @@ -159,7 +137,7 @@ impl EventRouter { Event::Grpc(grpc_event) => self.handle_grpc_event(*grpc_event)?, Event::Bidi(bidi_event) => self.handle_bidi_event(bidi_event)?, Event::Internal(internal_event) => self.handle_internal_event(*internal_event)?, - }; + } } } } diff --git a/crates/defguard_version/Cargo.toml b/crates/defguard_version/Cargo.toml new file mode 100644 index 0000000000..1d0197f3a5 --- /dev/null +++ b/crates/defguard_version/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "defguard_version" +version = "0.0.0" +edition.workspace = true +license-file.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +axum.workspace = true +http = "1.3" +os_info = "3.12" +semver.workspace = true +serde.workspace = true +thiserror.workspace = true +tonic.workspace = true +tower = "0.5" +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/defguard_version/src/client.rs b/crates/defguard_version/src/client.rs new file mode 100644 index 0000000000..e45c933f1c --- /dev/null +++ b/crates/defguard_version/src/client.rs @@ -0,0 +1,48 @@ +use tonic::{Request, Status, service::Interceptor}; +use tracing::warn; + +use crate::{ComponentInfo, SYSTEM_INFO_HEADER, VERSION_HEADER}; + +/// Adds version and system-info headers to outgoing requests +/// +/// # Headers Added +/// +/// - `defguard-version`: Semantic version of the component. +/// - `defguard-system`: System information including OS type, version and architecture. (only for gRPC, don't expose it in HTTP) +#[derive(Clone)] +pub struct ClientVersionInterceptor { + component_info: ComponentInfo, +} + +impl ClientVersionInterceptor { + #[must_use] + pub fn new(version: crate::Version) -> Self { + Self { + component_info: ComponentInfo::new(version), + } + } +} + +impl Interceptor for ClientVersionInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + let metadata = request.metadata_mut(); + + // Add version header + match self.component_info.version.to_string().parse() { + Ok(version_value) => { + metadata.insert(VERSION_HEADER, version_value); + } + Err(err) => warn!("Failed to parse version: {err}"), + } + + // Add system info header + match self.component_info.system.as_header_value().parse() { + Ok(system_info_value) => { + metadata.insert(SYSTEM_INFO_HEADER, system_info_value); + } + Err(err) => warn!("Failed to parse system info: {err}"), + } + + Ok(request) + } +} diff --git a/crates/defguard_version/src/lib.rs b/crates/defguard_version/src/lib.rs new file mode 100644 index 0000000000..18c19a1339 --- /dev/null +++ b/crates/defguard_version/src/lib.rs @@ -0,0 +1,406 @@ +//! Defguard version information handling for gRPC communications. +//! +//! This crate provides utilities for embedding and extracting version and system information +//! in gRPC communications between Defguard components. It supports both client-side and +//! server-side middleware for automatic version header management. +//! +//! # Headers +//! +//! The crate defines two standard headers used across all Defguard gRPC communications: +//! +//! - `defguard-version`: Semantic version string (e.g., "1.2.3") +//! - `defguard-system`: Semicolon-separated system information (OS;version;arch) +//! +//! # Usage +//! +//! ## Server-side middleware +//! +//! ```rust,ignore +//! use defguard_version::server::DefguardVersionLayer; +//! use semver::Version; +//! use tower::ServiceBuilder; +//! +//! let version = Version::parse("1.0.0").unwrap(); +//! let layer = DefguardVersionLayer::new(version); +//! let service = ServiceBuilder::new() +//! .layer(layer) +//! .service(my_grpc_service); +//! ``` +//! +//! ## Client-side interceptor +//! +//! ```rust,ignore +//! use defguard_version::client::version_interceptor; +//! use semver::Version; +//! use tonic::transport::Channel; +//! +//! let version = Version::parse("1.0.0").unwrap(); +//! let channel = Channel::from_static("http://localhost:50051").connect().await.unwrap(); +//! let client = MyServiceClient::with_interceptor( +//! channel, +//! version_interceptor(version) +//! ); +//! ``` +//! +//! ## Parsing version information +//! +//! ```rust +//! use defguard_version::{ComponentInfo, version_info_from_metadata}; +//! use tonic::metadata::MetadataMap; +//! +//! let metadata = MetadataMap::new(); +//! +//! // Extract parsed version and system info +//! if let Some(component_info) = ComponentInfo::from_metadata(&metadata) { +//! println!("Client version: {}", component_info.version); +//! println!("Client system: {}", component_info.system); +//! } +//! +//! // Get version info as strings (with fallback) +//! let (version_str, system_str) = version_info_from_metadata(&metadata); +//! ``` + +use std::{cmp::Ordering, fmt, str::FromStr}; + +use ::tracing::{error, warn}; +pub use semver::{BuildMetadata, Error as SemverError, Prerelease, Version}; +use serde::Serialize; +use thiserror::Error; +use tonic::metadata::MetadataMap; + +pub mod client; +pub mod server; +pub mod tracing; + +/// HTTP header name for the Defguard component version. +pub static VERSION_HEADER: &str = "defguard-component-version"; + +/// HTTP header name for the Defguard system information. +pub static SYSTEM_INFO_HEADER: &str = "defguard-component-system"; + +#[derive(Debug, Error)] +pub enum DefguardVersionError { + #[error(transparent)] + SemverError(#[from] semver::Error), + + #[error("Failed to parse SystemInfo header: {0}")] + SystemInfoParseError(String), + + #[error("Invalid DefguardComponent: {0}")] + InvalidDefguardComponent(String), +} + +/// Represents the different types of Defguard components that can communicate via gRPC. +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)] +pub enum DefguardComponent { + Core, + Proxy, + Gateway, +} + +impl FromStr for DefguardComponent { + type Err = DefguardVersionError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "core" => Ok(DefguardComponent::Core), + "proxy" => Ok(DefguardComponent::Proxy), + "gateway" => Ok(DefguardComponent::Gateway), + _ => Err(Self::Err::InvalidDefguardComponent(s.to_string())), + } + } +} + +impl fmt::Display for DefguardComponent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Core => write!(f, "core"), + Self::Proxy => write!(f, "proxy"), + Self::Gateway => write!(f, "gateway"), + } + } +} + +/// System information about the host running a Defguard component. +/// +/// This struct captures key system characteristics that are useful for +/// debugging, compatibility checking, and system analytics. The information +/// is automatically detected from the host system and can be serialized +/// into HTTP headers for transmission over gRPC. +/// +/// # Examples +/// +/// ```rust +/// use defguard_version::SystemInfo; +/// +/// // Get current system information +/// let info = SystemInfo::get(); +/// println!("Running on: {info}"); +/// +/// // Access individual fields +/// println!("OS: {} {}", info.os_type, info.os_version); +/// println!("Architecture: {}", info.architecture); +/// ``` +#[derive(Debug, Clone)] +pub struct SystemInfo { + /// The operating system type (e.g., "Linux", "Windows", "macOS") + pub os_type: String, + /// The operating system version (e.g., "22.04", "11", "13.0") + pub os_version: String, + /// The system architecture (e.g., "x86_64", "aarch64", "arm") + pub architecture: String, +} + +impl fmt::Display for SystemInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {} {}", + self.os_type, self.os_version, self.architecture + ) + } +} + +impl SystemInfo { + /// Automatically detects the operating system type, version and architecture + /// using the `os_info` crate. + /// + /// # Returns + /// + /// A `SystemInfo` struct populated with the current system's characteristics. + #[must_use] + pub fn get() -> Self { + os_info::get().into() + } + + fn as_header_value(&self) -> String { + format!("{};{};{}", self.os_type, self.os_version, self.architecture) + } + + fn try_from_header_value(header_value: &str) -> Result { + let parts: Vec<&str> = header_value.split(';').collect(); + if parts.len() != 3 { + return Err(DefguardVersionError::SystemInfoParseError( + header_value.to_string(), + )); + } + + Ok(Self { + os_type: parts[0].to_string(), + os_version: parts[1].to_string(), + architecture: parts[2].to_string(), + }) + } +} + +impl From for SystemInfo { + fn from(info: os_info::Info) -> Self { + Self { + os_type: info.os_type().to_string(), + os_version: info.version().to_string(), + architecture: info.architecture().unwrap_or("?").to_string(), + } + } +} + +/// Combined version and system information for a Defguard component. +/// +/// This struct bundles together both the semantic version of a component +/// and the system information of the host it's running on. It's used by +/// middleware to generate the appropriate headers for gRPC communication. +#[derive(Debug, Clone)] +pub struct ComponentInfo { + /// The semantic version of the component + pub version: Version, + /// System information about the host + pub system: SystemInfo, +} + +impl ComponentInfo { + /// Creates a new ComponentInfo with the provided version and automatically detects + /// the current system information. + /// + /// # Arguments + /// + /// * `version` - A parsed semantic version + /// + /// # Examples + /// + /// ``` + /// use defguard_version::ComponentInfo; + /// use semver::Version; + /// + /// let version = Version::parse("1.0.0").unwrap(); + /// let info = ComponentInfo::new(version); + /// assert_eq!(info.version.major, 1); + /// ``` + #[must_use] + pub fn new(version: Version) -> Self { + let info = os_info::get(); + Self { + version, + system: info.into(), + } + } + + /// Parses version and system information from gRPC metadata headers. + /// + /// This function extracts and parses the Defguard version headers from + /// gRPC metadata, returning structured version and system information. + /// If any parsing step fails, warnings are logged and `None` is returned. + /// + /// # Arguments + /// + /// * `metadata` - The gRPC metadata map containing headers + /// + /// # Returns + /// + /// * `Some(ComponentInfo)` - Successfully parsed version information. + /// * `None` - If headers are missing or parsing fails. + /// + /// # Examples + /// + /// ``` + /// use defguard_version::ComponentInfo; + /// use tonic::metadata::MetadataMap; + /// + /// let metadata = MetadataMap::new(); + /// if let Some(component_info) = ComponentInfo::from_metadata(&metadata) { + /// println!("Peer version: {}", component_info.version); + /// println!("Peer system: {}", component_info.system); + /// } + /// ``` + pub fn from_metadata(metadata: &MetadataMap) -> Option { + let Some(version) = metadata.get(VERSION_HEADER) else { + warn!("Missing version header"); + return None; + }; + let Some(system) = metadata.get(SYSTEM_INFO_HEADER) else { + warn!("Missing system info header"); + return None; + }; + let (Ok(version), Ok(system)) = (version.to_str(), system.to_str()) else { + warn!("Failed to stringify version or system info header value"); + return None; + }; + let Ok(version) = Version::from_str(version) else { + warn!("Failed to parse version: {version}"); + return None; + }; + let Ok(system) = SystemInfo::try_from_header_value(system) else { + warn!("Failed to parse system info: {system}"); + return None; + }; + + Some(Self { version, system }) + } +} + +/// Extracts version information from metadata as formatted strings with fallback. +/// +/// This is a convenience function that calls `parse_metadata` internally and +/// returns the version and system information as strings. If parsing fails, +/// it returns "?" for both values instead of `None`. +/// +/// # Arguments +/// +/// * `metadata` - The gRPC metadata map containing headers +/// +/// # Returns +/// +/// A tuple containing: +/// * Version string (or "?" if parsing failed) +/// * System info string (or "?" if parsing failed) +/// +/// # Examples +/// +/// ``` +/// use defguard_version::version_info_from_metadata; +/// use tonic::metadata::MetadataMap; +/// +/// let metadata = MetadataMap::new(); +/// let (version, system) = version_info_from_metadata(&metadata); +/// println!("Client: {version} running on {system}"); +/// // Output might be: "Client: 1.2.3 running on Linux 22.04 64-bit x86_64" +/// // Or if headers missing: "Client: ? running on ?" +/// ``` +#[must_use] +pub fn version_info_from_metadata(metadata: &MetadataMap) -> (Version, String) { + ComponentInfo::from_metadata(metadata) + .map_or((Version::new(0, 0, 0), String::from("?")), |info| { + (info.version, info.system.to_string()) + }) +} + +#[must_use] +pub fn get_tracing_variables(info: &Option) -> (Version, String) { + let version = info + .as_ref() + .map_or(Version::new(0, 0, 0), |info| info.version.clone()); + let info = info + .as_ref() + .map_or(String::from("?"), |info| info.system.to_string()); + + (version, info) +} + +/// Compares two versions while omitting pre-release and build metadata, which we use +/// for git commit hash. +/// Returns true if v1 < v2. +#[must_use] +pub fn is_version_lower(v1: &Version, v2: &Version) -> bool { + let (mut v1, mut v2) = (v1.clone(), v2.clone()); + // ignore pre-release + v1.pre = Prerelease::EMPTY; + v2.pre = Prerelease::EMPTY; + // ignore build metadata + v1.cmp_precedence(&v2) == Ordering::Less +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_version_comparison() { + let v1 = Version::parse("1.5.0").unwrap(); + let v2 = Version::parse("1.6.0").unwrap(); + assert!(is_version_lower(&v1, &v2)); + + let v1 = Version::parse("1.5.0-alpha1").unwrap(); + let v2 = Version::parse("1.5.0").unwrap(); + assert!(!is_version_lower(&v1, &v2)); + + let v1 = Version::parse("1.5.0").unwrap(); + let v2 = Version::parse("1.5.0-alpha1").unwrap(); + assert!(!is_version_lower(&v1, &v2)); + + let v1 = Version::parse("1.5.0").unwrap(); + let v2 = Version::parse("1.6.0-rc1").unwrap(); + assert!(is_version_lower(&v1, &v2)); + + let v1 = Version::parse("1.5.0-rc1").unwrap(); + let v2 = Version::parse("1.6.0").unwrap(); + assert!(is_version_lower(&v1, &v2)); + + let v1 = Version::parse("1.5.0-alpha1").unwrap(); + let v2 = Version::parse("1.5.0-alpha2").unwrap(); + assert!(!is_version_lower(&v1, &v2)); + + let v1 = Version::parse("1.5.0-alpha2").unwrap(); + let v2 = Version::parse("1.5.0-alpha1").unwrap(); + assert!(!is_version_lower(&v1, &v2)); + + let v1 = Version::parse("1.5.0+1").unwrap(); + let v2 = Version::parse("1.5.0+2").unwrap(); + assert!(!is_version_lower(&v1, &v2)); + + let v1 = Version::parse("1.5.0+2").unwrap(); + let v2 = Version::parse("1.5.0+1").unwrap(); + assert!(!is_version_lower(&v1, &v2)); + + let v1 = Version::parse("1.5.0-alpha1+2").unwrap(); + let v2 = Version::parse("1.5.0-alpha2+1").unwrap(); + assert!(!is_version_lower(&v1, &v2)); + } +} diff --git a/crates/defguard_version/src/server/grpc.rs b/crates/defguard_version/src/server/grpc.rs new file mode 100644 index 0000000000..3a90b34509 --- /dev/null +++ b/crates/defguard_version/src/server/grpc.rs @@ -0,0 +1,176 @@ +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, +}; + +use http::HeaderValue; +use tonic::{ + Status, + body::Body, + codegen::http::{Request, Response}, + server::NamedService, + service::Interceptor, +}; +use tower::Service; +use tracing::{debug, error}; + +use crate::{ + ComponentInfo, DefguardComponent, SYSTEM_INFO_HEADER, VERSION_HEADER, Version, + is_version_lower, server::DefguardVersionService, +}; + +impl Service> for DefguardVersionService +where + S: Service, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, +{ + type Error = S::Error; + type Future = Pin> + Send>>; + type Response = Response; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + // Delegate readiness polling to the inner service + self.inner.poll_ready(cx) + } + + fn call(&mut self, request: Request) -> Self::Future { + let mut inner = self.inner.clone(); + + // Pre-parse header values + let parsed_info = ( + self.component_info + .version + .to_string() + .parse::() + .ok(), + self.component_info + .system + .as_header_value() + .parse::() + .ok(), + ); + + Box::pin(async move { + // Process the request with the inner service first + let mut response = inner.call(request).await?; + + // Add version headers + if let (Some(version), Some(system)) = parsed_info { + response.headers_mut().insert(VERSION_HEADER, version); + response.headers_mut().insert(SYSTEM_INFO_HEADER, system); + } + + Ok(response) + }) + } +} + +/// Interceptor for `tonic` that validates client version information from request headers. +/// +/// This interceptor extracts version headers from incoming gRPC requests and validates them +/// against configured version requirements. It can enforce both minimum version requirements and +/// optionally reject clients with versions higher than the server's own version. +/// +/// # Version Validation Rules +/// +/// 1. **Missing Version**: If the client doesn't provide version headers, the request is rejected +/// 2. **Below Minimum**: If the client version is below `min_version`, the request is rejected +/// 3. **Too High** (optional): If `fail_if_client_version_is_higher` is true and the client +/// version exceeds `own_version`, the request is rejected +/// +/// # Fields +/// +/// * `own_version` – The server's own version, used for upper bound validation +/// * `component` – The expected client component type (e.g., Gateway, Core) +/// * `min_version` – Minimum required client version +/// * `fail_if_client_version_is_higher` – Whether to reject clients with versions higher than the +/// server +#[derive(Clone)] +pub struct DefguardVersionInterceptor { + own_version: Version, + component: DefguardComponent, + min_version: Version, + /// When true, reject clients with versions higher than the server's own version. + /// This is used as a workaround for version compatibility checking in gateway->core + /// communication, where the core UI needs to display version compatibility errors + /// that would normally only be detectable on the gateway side (core < gateway). + fail_if_client_version_is_higher: bool, +} + +impl DefguardVersionInterceptor { + #[must_use] + pub fn new( + own_version: Version, + component: DefguardComponent, + min_version: Version, + fail_if_client_version_is_higher: bool, + ) -> Self { + Self { + own_version, + component, + min_version, + fail_if_client_version_is_higher, + } + } + + #[must_use] + fn is_component_version_supported(&self, version: Option<&Version>) -> bool { + let Some(version) = version else { + error!( + "Missing {0} version information. This most likely means that {0} component uses \ + older, unsupported version. Minimal supported version is {1}.", + self.component, self.min_version, + ); + return false; + }; + + if is_version_lower(version, &self.min_version) { + error!( + "{0} version {version} is not supported. Minimal supported {0} version is {1}.", + self.component, self.min_version + ); + return false; + } + + if self.fail_if_client_version_is_higher && is_version_lower(&self.own_version, version) { + error!( + "{} client version {version} is higher than server version {}.", + self.component, self.own_version + ); + return false; + } + + debug!("{} version {version} is supported", self.component); + true + } +} + +impl Interceptor for DefguardVersionInterceptor { + fn call(&mut self, request: tonic::Request<()>) -> Result, Status> { + let maybe_info = ComponentInfo::from_metadata(request.metadata()); + let version = maybe_info.as_ref().map(|info| &info.version); + if !self.is_component_version_supported(version) { + let msg = match version { + Some(version) => format!("Version {version} not supported"), + None => "Missing version headers".to_string(), + }; + return Err(Status::failed_precondition(msg)); + } + + Ok(request) + } +} + +/// Implementation of `NamedService` that delegates to the inner service. +/// +/// This ensures that the wrapped service maintains its original service name +/// for tonic's service discovery and routing mechanisms. The version middleware +/// is transparent from the perspective of service identification. +impl NamedService for DefguardVersionService +where + S: NamedService, +{ + const NAME: &'static str = S::NAME; +} diff --git a/crates/defguard_version/src/server/http.rs b/crates/defguard_version/src/server/http.rs new file mode 100644 index 0000000000..98751e44f4 --- /dev/null +++ b/crates/defguard_version/src/server/http.rs @@ -0,0 +1,52 @@ +use std::{ + pin::Pin, + task::{Context, Poll}, +}; + +use axum::{extract::Request, http, response::Response}; +use http::HeaderValue; +use tower::Service; + +use crate::{VERSION_HEADER, server::DefguardVersionService}; + +impl Service for DefguardVersionService +where + S: Service> + Clone + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, +{ + type Error = S::Error; + type Future = Pin> + Send>>; + type Response = Response; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + // Delegate readiness polling to the inner service + self.inner.poll_ready(cx) + } + + fn call(&mut self, request: Request) -> Self::Future { + let mut inner = self.inner.clone(); + + // Pre-parse header values + // + // We avoid adding a system header here deliberately. + let parsed_info = self + .component_info + .version + .to_string() + .parse::() + .ok(); + + Box::pin(async move { + // Process the request with the inner service first + let mut response = inner.call(request).await?; + + // Add version headers + if let Some(version) = parsed_info { + response.headers_mut().insert(VERSION_HEADER, version); + } + + Ok(response) + }) + } +} diff --git a/crates/defguard_version/src/server/mod.rs b/crates/defguard_version/src/server/mod.rs new file mode 100644 index 0000000000..dfc3699d78 --- /dev/null +++ b/crates/defguard_version/src/server/mod.rs @@ -0,0 +1,100 @@ +//! Server-side middleware for adding Defguard version information to HTTP responses (either plain HTTP or gRPC). +//! +//! This module provides a tower-based middleware layer that automatically adds version +//! and system information headers to all HTTP responses. It can be used both with tonic and axum. +//! +//! The middleware is designed to +//! work with tonic's interceptor system and maintains compatibility with both regular +//! and intercepted services. +//! +//! # Headers Added +//! +//! - `defguard-version`: The semantic version of the Defguard component +//! - `defguard-system`: System information including OS type, version, and architecture +//! +//! # Usage +//! +//! ``` +//! use tower::ServiceBuilder; +//! use defguard_version::server::DefguardVersionLayer; +//! use semver::Version; +//! +//! let my_grpc_service = ServiceBuilder::new(); +//! let version = Version::parse("1.0.0").unwrap(); +//! let version_layer = DefguardVersionLayer::new(version); +//! let service = ServiceBuilder::new() +//! .layer(version_layer) +//! .service(my_grpc_service); +//! ``` + +use tower::Layer; + +use crate::ComponentInfo; + +pub mod grpc; +pub mod http; + +/// A tower `Layer` that adds Defguard version and system information headers to HTTP responses. +/// +/// This layer wraps any service and ensures that all responses include version metadata +/// in HTTP headers. The layer is designed to be composable with other tower layers and +/// maintains the original service's `NamedService` implementation for tonic compatibility. +/// +/// # Fields +/// +/// * `component_info` - Contains version and system information that will be added to response +/// headers. +#[derive(Clone)] +pub struct DefguardVersionLayer { + component_info: ComponentInfo, +} + +impl DefguardVersionLayer { + /// Creates a new version layer with the specified version string. + /// + /// # Arguments + /// + /// * `version` - Semantic version of the component + /// + /// # Returns + /// + /// * `Ok(DefguardVersionLayer)` - A new layer instance + /// * `Err(DefguardVersionError)` - If the version string cannot be parsed + #[must_use] + pub fn new(version: crate::Version) -> Self { + Self { + component_info: ComponentInfo::new(version), + } + } +} + +impl Layer for DefguardVersionLayer { + type Service = DefguardVersionService; + + fn layer(&self, inner: S) -> Self::Service { + DefguardVersionService { + inner, + component_info: self.component_info.clone(), + } + } +} + +/// A tower `Service` that wraps another service and adds version headers to responses. +/// +/// This service is created by the `DefguardVersionLayer` and implements the actual +/// header injection logic. It maintains full compatibility with the wrapped service's +/// interface while adding the version metadata functionality. +/// +/// # Type Parameters +/// +/// * `S` - The inner service type being wrapped +/// +/// # Fields +/// +/// * `inner` - The wrapped service that handles the actual request processing +/// * `component_info` - Version and system information to be added to response headers +#[derive(Clone)] +pub struct DefguardVersionService { + inner: S, + component_info: ComponentInfo, +} diff --git a/crates/defguard_version/src/tracing.rs b/crates/defguard_version/src/tracing.rs new file mode 100644 index 0000000000..c81793499b --- /dev/null +++ b/crates/defguard_version/src/tracing.rs @@ -0,0 +1,445 @@ +//! Tracing integration with version-aware log formatting. +//! +//! This module provides a custom tracing formatter and layer system that automatically +//! includes version and system information in log messages. It's designed to make +//! debugging and monitoring easier in distributed Defguard deployments by providing +//! component version context in logs. +//! +//! # Features +//! +//! - **Version-aware formatting**: Automatically extracts and displays version information +//! - **Component differentiation**: Distinguishes between Core (C:), Proxy (PX:), and Gateway (GW:) components +//! - **Error-level enhancement**: Includes detailed system information for ERROR-level logs +//! +//! # Log Format +//! +//! The formatter adds version suffixes to log messages: +//! +//! - **Regular logs**: `[own_version][C:core_version][PX:proxy_version][GW:gateway_version]` +//! - **Error logs**: `[own_version own_system_info][C:core_version core_info][PX:proxy_version proxy_info][GW:gateway_version gateway_info]` +//! +//! # Span Fields +//! +//! The following span fields are automatically captured and used for version display: +//! +//! - `component` - component name to use, one of `DefguardComponent` variants +//! - `version` - component version, usually retrieved from the headers +//! - `info` - system information, usually retrieved from the headers +//! +//! # Usage +//! +//! ## Basic Setup +//! +//! ```rust +//! // Initialize tracing with version-aware formatting +//! use semver::Version; +//! +//! let version = Version::parse("1.5.0").unwrap(); +//! defguard_version::tracing::init(version, "info"); +//! ``` +//! +//! ## Creating Version-Aware Spans +//! +//! ```rust +//! use defguard_version::DefguardComponent; +//! use tracing::info_span; +//! +//! // Create a span with proxy version information +//! let _span = info_span!( +//! "proxy_communication", +//! component = %DefguardComponent::Proxy, +//! version = "1.4.2", +//! info = "Linux 22.04 64-bit x86_64" +//! ).entered(); +//! +//! // This log will include the proxy version information +//! tracing::info!("Processing proxy request"); +//! // Output: 2024-01-01T12:00:00Z INFO proxy_communication: Processing proxy request [1.5.0][PX:1.4.2] +//! ``` +//! +//! ## Error Logs with Full Context +//! +//! ```rust +//! use tracing::error; +//! +//! // Error logs automatically include system information +//! tracing::error!("Failed to connect to gateway"); +//! // Output: 2024-01-01T12:00:00Z ERROR: Failed to connect to gateway [1.5.0 Linux 22.04 64-bit x86_64][GW:1.3.1 Windows 11 64-bit x86_64] +//! ``` +//! +//! # Architecture +//! +//! The module implements a layered architecture: +//! +//! 1. **`VersionFieldLayer`** - Captures version fields from spans and stores them in extensions +//! 2. **`VersionSuffixFormat`** - Custom formatter that adds version suffixes to log messages +//! 3. **`VersionFilteredFields`** - Field formatter that excludes version fields from normal output +//! 4. **Utility functions** - Extract and format version information from span hierarchy + +use std::{fmt, str::FromStr}; + +use semver::Version; +use serde::Serialize; +use tracing::{Level, Subscriber}; +use tracing_subscriber::{ + EnvFilter, Layer, + field::RecordFields, + fmt::{ + FmtContext, FormatEvent, FormatFields, + format::{Format, Full, Writer}, + time::SystemTime, + }, + layer::{Context, SubscriberExt}, + registry::LookupSpan, + util::SubscriberInitExt, +}; + +use crate::{ComponentInfo, DefguardComponent, DefguardVersionError, SystemInfo}; + +/// Container for version information extracted from tracing span hierarchy. +/// +/// Aggregates version and system information found while traversing up the span tree. +#[derive(Clone, Debug, Default, Serialize)] +pub struct VersionInfo { + pub component: Option, + pub info: Option, + pub version: Option, + // FIXME: currently used only in `outdated_components()`. + pub is_supported: bool, +} + +impl VersionInfo { + #[must_use] + pub fn has_version_info(&self) -> bool { + self.component.is_some() || self.info.is_some() || self.version.is_some() + } +} + +/// Extract version information from current span context +/// +/// This function extracts version information from the current span's extensions +/// that were stored by VersionFieldLayer. +/// +/// # Arguments +/// * `ctx` - The format context from the tracing formatter +/// +/// # Returns +/// An `ExtractedVersionInfo` struct containing all version information found in the current span +#[must_use] +pub fn extract_version_info_from_context(ctx: &FmtContext<'_, S, N>) -> VersionInfo +where + S: Subscriber + for<'a> LookupSpan<'a>, + N: for<'a> FormatFields<'a> + 'static, +{ + let mut extracted = VersionInfo::default(); + + if let Some(span_ref) = ctx.lookup_current() { + let mut current_span = Some(span_ref); + while let Some(span) = current_span { + let extensions = span.extensions(); + + if let Some(stored_visitor) = extensions.get::() { + if extracted.component.is_none() && stored_visitor.component.is_some() { + extracted.component.clone_from(&stored_visitor.component); + } + if extracted.version.is_none() && stored_visitor.version.is_some() { + extracted.version.clone_from(&stored_visitor.version); + } + if extracted.info.is_none() && stored_visitor.info.is_some() { + extracted.info.clone_from(&stored_visitor.info); + } + } + current_span = span.parent(); + } + } + + extracted +} + +/// Build a version suffix string based on extracted version info +/// +/// # Arguments +/// * `extracted` - The extracted version information +/// * `own_version` - The application's own version +/// * `own_info` - The application's own system info +/// * `is_error` - Whether this is for an ERROR level log +/// +/// # Returns +/// A formatted string containing version information suitable for appending to log lines +#[must_use] +pub fn build_version_suffix( + extracted: &VersionInfo, + own_version: &Version, + own_info: &SystemInfo, + is_error: bool, +) -> String { + let mut version_suffix = String::new(); + let is_versioned_span = extracted.has_version_info(); + + if is_versioned_span || is_error { + // Own version + version_suffix.push_str(" ["); + version_suffix.push_str(&own_version.to_string()); + if is_error { + version_suffix.push(' '); + version_suffix.push_str(&own_info.to_string()); + } + version_suffix.push(']'); + } + + if let (Some(component), Some(version)) = (&extracted.component, &extracted.version) { + match component { + DefguardComponent::Core => version_suffix.push_str("[C:"), + DefguardComponent::Proxy => version_suffix.push_str("[PX:"), + DefguardComponent::Gateway => version_suffix.push_str("[GW:"), + } + version_suffix.push_str(version); + if is_error { + if let Some(ref info) = extracted.info { + version_suffix.push(' '); + version_suffix.push_str(info); + } + } + version_suffix.push(']'); + } + + version_suffix +} + +/// Custom tracing formatter that conditionally includes version information in log messages. +/// +/// This formatter wraps the default tracing formatter and adds version suffix to log messages: +/// - For ERROR level logs: includes own_version, own_info and components version and info +/// - For other levels: includes only own_version and component version if available +/// +/// The version information is extracted from tracing span fields. +pub struct VersionSuffixFormat { + /// The underlying tracing formatter + pub inner: Format, + pub component_info: ComponentInfo, +} + +impl VersionSuffixFormat { + #[must_use] + pub fn new(own_version: crate::Version, inner: Format) -> Self { + Self { + inner, + component_info: ComponentInfo::new(own_version), + } + } +} + +/// A layer that captures version fields from spans and stores them for use by the formatter +pub struct VersionFieldLayer; + +impl Layer for VersionFieldLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_new_span( + &self, + attrs: &tracing::span::Attributes<'_>, + id: &tracing::span::Id, + ctx: Context<'_, S>, + ) { + if let Some(span) = ctx.span(id) { + let mut visitor = SpanFieldVisitor::default(); + attrs.record(&mut visitor); + span.extensions_mut().insert(visitor); + } + } +} + +impl FormatEvent for VersionSuffixFormat +where + S: Subscriber + for<'a> LookupSpan<'a>, + N: for<'a> FormatFields<'a> + 'static, +{ + /// Formats a tracing event, conditionally adding version information as a prefix. + /// + /// This method includes version information based on: + /// - For ERROR level logs: includes own and remote component version and system-info + /// - For other levels: includes only own and remote component version + fn format_event( + &self, + ctx: &FmtContext<'_, S, N>, + writer: Writer<'_>, + event: &tracing::Event<'_>, + ) -> fmt::Result { + // Extract version information from current span context + let extracted = extract_version_info_from_context(ctx); + + // Build version suffix + let is_error = *event.metadata().level() == Level::ERROR; + let version_suffix = build_version_suffix( + &extracted, + &self.component_info.version, + &self.component_info.system, + is_error, + ); + + // Create a wrapper writer that will append version info before newlines + let mut wrapper = VersionSuffixWriter::new(writer, version_suffix); + self.inner + .format_event(ctx, Writer::new(&mut wrapper), event) + } +} + +/// A wrapper writer that appends version suffix before newlines +pub struct VersionSuffixWriter<'a> { + inner: Writer<'a>, + version_suffix: String, +} + +impl<'a> VersionSuffixWriter<'a> { + #[must_use] + pub fn new(inner: Writer<'a>, version_suffix: String) -> Self { + Self { + inner, + version_suffix, + } + } +} + +impl fmt::Write for VersionSuffixWriter<'_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + // Replace newline characters with escaped version to prevent log line splitting + let escaped = s.replace('\n', "\\n"); + + if let Some(content) = escaped.strip_suffix("\\n") { + // If the original string ended with a newline, add version suffix and restore newline + writeln!(self.inner, "{content}{}", self.version_suffix) + } else { + // No trailing newline, just write the escaped content + write!(self.inner, "{escaped}") + } + } +} + +/// A visitor that extracts version fields from spans +#[derive(Default, Clone)] +pub struct SpanFieldVisitor { + pub component: Option, + pub info: Option, + pub version: Option, +} + +impl tracing::field::Visit for SpanFieldVisitor { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + match field.name() { + // "component" => self.component = DefguardComponent::from_str(value).ok(), + "version" => self.version = Some(value.to_string()), + "info" => self.info = Some(value.to_string()), + _ => {} + } + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) { + let value = format!("{value:?}"); + match field.name() { + "component" => self.component = DefguardComponent::from_str(&value).ok(), + "version" => self.version = Some(value), + "info" => self.info = Some(value), + _ => {} + } + } +} + +/// Custom field formatter that filters out version fields to prevent duplication +pub struct VersionFilteredFields; + +impl<'writer> FormatFields<'writer> for VersionFilteredFields { + fn format_fields(&self, writer: Writer<'writer>, fields: R) -> fmt::Result { + let mut visitor = FieldFilterVisitor::new(writer); + fields.record(&mut visitor); + Ok(()) + } +} + +/// Field visitor that skips version-related fields to avoid duplication +pub struct FieldFilterVisitor<'writer> { + writer: Writer<'writer>, + first: bool, +} + +impl<'writer> FieldFilterVisitor<'writer> { + #[must_use] + pub fn new(writer: Writer<'writer>) -> Self { + Self { + writer, + first: true, + } + } +} + +impl tracing::field::Visit for FieldFilterVisitor<'_> { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + match field.name() { + "component" | "info" | "version" => { + // Skip version fields to prevent duplication + } + _ => { + if !self.first { + let _ = write!(self.writer, " "); + } + let _ = write!(self.writer, "{}={value}", field.name()); + self.first = false; + } + } + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) { + match field.name() { + "component" | "info" | "version" => { + // Skip version fields to prevent duplication + } + _ => { + if !self.first { + let _ = write!(self.writer, " "); + } + let _ = write!(self.writer, "{}={value:?}", field.name()); + self.first = false; + } + } + } +} + +/// Initializes tracing with custom formatter that conditionally displays version information. +/// +/// The formatter will: +/// - For ERROR level logs: display own and remote component version and system-info +/// - For other log levels: display only own and remote component version +/// +/// Version information is extracted from tracing span fields with names: +/// - `component` - component name to use, one of `DefguardComponent` variants +/// - `version` - component version, usually retrieved from the headers +/// - `info` - system information, usually retrieved from the headers +/// +/// # Arguments +/// * `own_version` - The application semantic version +/// * `log_level` - The log level filter to use +/// +/// # Examples +/// ``` +/// defguard_version::tracing::init(defguard_version::Version::new(1, 5, 0), "info"); +/// ``` +pub fn init(own_version: crate::Version, log_level: &str) -> Result<(), DefguardVersionError> { + tracing_subscriber::registry() + .with( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| format!("{log_level},h2=info").into()), + ) + .with(VersionFieldLayer) + .with( + tracing_subscriber::fmt::layer() + .with_ansi(true) + .event_format(VersionSuffixFormat::new( + own_version, + Format::default().with_ansi(true), + )) + .fmt_fields(VersionFilteredFields), + ) + .init(); + + Ok(()) +} diff --git a/defguard.service b/defguard.service index a3cf590d7b..6e36c0040d 100644 --- a/defguard.service +++ b/defguard.service @@ -7,7 +7,6 @@ After=network-online.target [Service] DynamicUser=yes User=defguard -ExecReload=/bin/kill -HUP $MAINPID EnvironmentFile=/etc/defguard/core.conf ExecStart=/usr/bin/defguard KillMode=process diff --git a/deny.toml b/deny.toml index 67225fb6ba..3e865a910a 100644 --- a/deny.toml +++ b/deny.toml @@ -108,12 +108,27 @@ confidence-threshold = 0.8 # Allow 1 or more licenses on a per-crate basis, so that particular licenses # aren't accepted for every possible crate as with the normal allow list exceptions = [ - { allow = ["AGPL-3.0"], crate = "defguard" }, - { allow = ["AGPL-3.0"], crate = "defguard_core" }, - { allow = ["AGPL-3.0"], crate = "defguard_web_ui" }, - { allow = ["AGPL-3.0"], crate = "defguard_event_router" }, - { allow = ["AGPL-3.0"], crate = "defguard_event_logger" }, - { allow = ["AGPL-3.0"], crate = "model_derive" }, + { allow = [ + "AGPL-3.0-only", + ], crate = "defguard" }, + { allow = [ + "AGPL-3.0-only", + ], crate = "defguard_core" }, + { allow = [ + "AGPL-3.0-only", + ], crate = "defguard_web_ui" }, + { allow = [ + "AGPL-3.0-only", + ], crate = "defguard_event_router" }, + { allow = [ + "AGPL-3.0-only", + ], crate = "defguard_event_logger" }, + { allow = [ + "AGPL-3.0-only", + ], crate = "defguard_version" }, + { allow = [ + "AGPL-3.0-only", + ], crate = "model_derive" }, ] # Some crates don't have (easily) machine readable licensing information, diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 91f4941184..9b031b4fa3 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -1,9 +1,6 @@ services: core: image: ghcr.io/defguard/defguard:${IMAGE_TAG} - # build: - # context: . - # dockerfile: Dockerfile environment: DEFGUARD_DEFAULT_ADMIN_PASSWORD: pass123 DEFGUARD_COOKIE_INSECURE: true @@ -27,7 +24,7 @@ services: - db db: - image: postgres:15-alpine + image: postgres:17-alpine environment: POSTGRES_DB: defguard POSTGRES_USER: defguard diff --git a/docker-compose.yaml b/docker-compose.yaml index d4c51e2bc4..8cf2fd08f1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,6 @@ services: core: - image: ghcr.io/defguard/defguard:latest + image: ghcr.io/defguard/defguard build: context: . dockerfile: Dockerfile @@ -41,7 +41,7 @@ services: - NET_ADMIN db: - image: postgres:15-alpine + image: postgres:17-alpine environment: POSTGRES_DB: defguard POSTGRES_USER: defguard diff --git a/e2e/README.md b/e2e/README.md index 4b8df19327..b892c38d0f 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,26 +1,51 @@ -# Defguard E2E tests powered by Playwright +# Defguard end-to-end tests powered by Playwright ## Prerequisites -- Docker -- Docker compose +- Docker with compose plugin - Node - pnpm ## How to run -Pull docker images: + +If needed, specifiy image tag by setting `IMAGE_TAG` variable: + +```bash +export IMAGE_TAG=release-1.5-alpha +``` + +Pull Docker images: + ```bash -docker-compose --file ../docker-compose.e2e.yaml pull +docker compose --file ../docker-compose.e2e.yaml pull ``` + Install packages: + ```bash pnpm install ``` -Install playwright chromium driver: + +Install Playwright with Chromium driver: + ```bash npx playwright install --with-deps chromium ``` + +or + +```bash +pnpm playwright install --with-deps chromium +``` + Run tests: + ```bash pnpm test ``` + +Run tests with the browser on screen, and stopping on failure: + +```bash +pnpm test --headed --max-failures 1 +``` diff --git a/e2e/package.json b/e2e/package.json index 209237b493..72eb2257c5 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,38 +5,38 @@ "type": "commonjs", "scripts": { "lint": "pnpm prettier --check './tests/**.ts' './utils/**/*.ts' && pnpm eslint './tests/**.ts' './utils/**/*.ts'", - "fix": "pnpm prettier -w ./tests/**/*.ts ./utils/**/*.ts && pnpm eslint --fix ./tests/**/*.ts ./utils/**/*.ts", + "fix": "pnpm prettier -w './tests/**/*.ts' './utils/**/*.ts' && pnpm eslint --fix ./tests/**/*.ts ./utils/**/*.ts", "test": "pnpm playwright test" }, "keywords": [], "author": "", "devDependencies": { - "@eslint/compat": "^1.3.1", + "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.30.1", + "@eslint/js": "^9.34.0", "@prettier/plugin-oxc": "^0.0.4", - "@types/node": "^22.16.2", + "@types/node": "^22.18.0", "@types/totp-generator": "^0.0.8", - "@typescript-eslint/eslint-plugin": "^8.36.0", - "@typescript-eslint/parser": "^8.36.0", - "eslint": "^9.30.1", - "eslint-config-prettier": "^10.1.5", + "@typescript-eslint/eslint-plugin": "^8.41.0", + "@typescript-eslint/parser": "^8.41.0", + "eslint": "^9.34.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-prettier": "^5.5.1", + "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-simple-import-sort": "^12.1.1", "prettier": "^3.6.2" }, "dependencies": { "@faker-js/faker": "^9.9.0", - "@playwright/test": "^1.53.2", + "@playwright/test": "^1.55.0", "@scure/base": "^1.2.6", "@types/lodash": "^4.17.20", - "@types/pg": "^8.15.4", - "axios": "^1.10.0", - "dotenv": "^17.2.0", + "@types/pg": "^8.15.5", + "axios": "^1.11.0", + "dotenv": "^17.2.1", "lodash": "^4.17.21", "pg": "^8.16.3", - "playwright": "^1.53.2", + "playwright": "^1.55.0", "totp-generator": "^1.0.0" }, "volta": { diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index 40083baba0..9a7e0e96d6 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^9.9.0 version: 9.9.0 '@playwright/test': - specifier: ^1.53.2 - version: 1.53.2 + specifier: ^1.55.0 + version: 1.55.0 '@scure/base': specifier: ^1.2.6 version: 1.2.6 @@ -21,14 +21,14 @@ importers: specifier: ^4.17.20 version: 4.17.20 '@types/pg': - specifier: ^8.15.4 - version: 8.15.4 + specifier: ^8.15.5 + version: 8.15.5 axios: - specifier: ^1.10.0 - version: 1.10.0 + specifier: ^1.11.0 + version: 1.11.0 dotenv: - specifier: ^17.2.0 - version: 17.2.0 + specifier: ^17.2.1 + version: 17.2.1 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -36,65 +36,65 @@ importers: specifier: ^8.16.3 version: 8.16.3 playwright: - specifier: ^1.53.2 - version: 1.53.2 + specifier: ^1.55.0 + version: 1.55.0 totp-generator: specifier: ^1.0.0 version: 1.0.0 devDependencies: '@eslint/compat': - specifier: ^1.3.1 - version: 1.3.1(eslint@9.30.1) + specifier: ^1.3.2 + version: 1.3.2(eslint@9.34.0) '@eslint/eslintrc': specifier: ^3.3.1 version: 3.3.1 '@eslint/js': - specifier: ^9.30.1 - version: 9.30.1 + specifier: ^9.34.0 + version: 9.34.0 '@prettier/plugin-oxc': specifier: ^0.0.4 version: 0.0.4 '@types/node': - specifier: ^22.16.2 - version: 22.16.2 + specifier: ^22.18.0 + version: 22.18.0 '@types/totp-generator': specifier: ^0.0.8 version: 0.0.8 '@typescript-eslint/eslint-plugin': - specifier: ^8.36.0 - version: 8.36.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1)(typescript@5.8.2))(eslint@9.30.1)(typescript@5.8.2) + specifier: ^8.41.0 + version: 8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0)(typescript@5.8.2))(eslint@9.34.0)(typescript@5.8.2) '@typescript-eslint/parser': - specifier: ^8.36.0 - version: 8.36.0(eslint@9.30.1)(typescript@5.8.2) + specifier: ^8.41.0 + version: 8.41.0(eslint@9.34.0)(typescript@5.8.2) eslint: - specifier: ^9.30.1 - version: 9.30.1 + specifier: ^9.34.0 + version: 9.34.0 eslint-config-prettier: - specifier: ^10.1.5 - version: 10.1.5(eslint@9.30.1) + specifier: ^10.1.8 + version: 10.1.8(eslint@9.34.0) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1)(typescript@5.8.2))(eslint@9.30.1) + version: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0)(typescript@5.8.2))(eslint@9.34.0) eslint-plugin-prettier: - specifier: ^5.5.1 - version: 5.5.1(eslint-config-prettier@10.1.5(eslint@9.30.1))(eslint@9.30.1)(prettier@3.6.2) + specifier: ^5.5.4 + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.34.0))(eslint@9.34.0)(prettier@3.6.2) eslint-plugin-simple-import-sort: specifier: ^12.1.1 - version: 12.1.1(eslint@9.30.1) + version: 12.1.1(eslint@9.34.0) prettier: specifier: ^3.6.2 version: 3.6.2 packages: - '@emnapi/core@1.4.4': - resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==} + '@emnapi/core@1.4.5': + resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} - '@emnapi/runtime@1.4.4': - resolution: {integrity: sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==} + '@emnapi/runtime@1.4.5': + resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} - '@emnapi/wasi-threads@1.0.3': - resolution: {integrity: sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==} + '@emnapi/wasi-threads@1.0.4': + resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} '@eslint-community/eslint-utils@4.7.0': resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} @@ -106,8 +106,8 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/compat@1.3.1': - resolution: {integrity: sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==} + '@eslint/compat@1.3.2': + resolution: {integrity: sha512-jRNwzTbd6p2Rw4sZ1CgWRS8YMtqG15YyZf7zvb6gY2rB2u6n+2Z+ELW0GtL0fQgyl0pr4Y/BzBfng/BdsereRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.40 || 9 @@ -119,32 +119,28 @@ packages: resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.3.0': - resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==} + '@eslint/config-helpers@0.3.1': + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/core@0.14.0': - resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.15.1': - resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==} + '@eslint/core@0.15.2': + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/eslintrc@3.3.1': resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.30.1': - resolution: {integrity: sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==} + '@eslint/js@9.34.0': + resolution: {integrity: sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.6': resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.3.3': - resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==} + '@eslint/plugin-kit@0.3.5': + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@faker-js/faker@9.9.0': @@ -171,8 +167,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@napi-rs/wasm-runtime@0.2.11': - resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -278,12 +274,12 @@ packages: '@oxc-project/types@0.74.0': resolution: {integrity: sha512-KOw/RZrVlHGhCXh1RufBFF7Nuo7HdY5w1lRJukM/igIl6x9qtz8QycDvZdzb4qnHO7znrPyo2sJrFJK2eKHgfQ==} - '@pkgr/core@0.2.7': - resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.53.2': - resolution: {integrity: sha512-tEB2U5z74ebBeyfGNZ3Jfg29AnW+5HlWhvHtb/Mqco9pFdZU1ZLNdVb2UtB5CvmiilNr2ZfVH/qMmAROG/XTzw==} + '@playwright/test@1.55.0': + resolution: {integrity: sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==} engines: {node: '>=18'} hasBin: true @@ -297,8 +293,8 @@ packages: '@scure/base@1.2.6': resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} - '@tybys/wasm-util@0.9.0': - resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@tybys/wasm-util@0.10.0': + resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -312,72 +308,72 @@ packages: '@types/lodash@4.17.20': resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} - '@types/node@22.16.2': - resolution: {integrity: sha512-Cdqa/eJTvt4fC4wmq1Mcc0CPUjp/Qy2FGqLza3z3pKymsI969TcZ54diNJv8UYUgeWxyb8FSbCkhdR6WqmUFhA==} + '@types/node@22.18.0': + resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} - '@types/pg@8.15.4': - resolution: {integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==} + '@types/pg@8.15.5': + resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==} '@types/totp-generator@0.0.8': resolution: {integrity: sha512-NAiruWgCYxW1sd2LjpTT/sVarogTjjQoUyLiiL/e2DZOz9eovKxzEx5BiH8YgZoKUDZql1VXiIzgDBK7VzOmNw==} - '@typescript-eslint/eslint-plugin@8.36.0': - resolution: {integrity: sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==} + '@typescript-eslint/eslint-plugin@8.41.0': + resolution: {integrity: sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.36.0 + '@typescript-eslint/parser': ^8.41.0 eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.36.0': - resolution: {integrity: sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==} + '@typescript-eslint/parser@8.41.0': + resolution: {integrity: sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.36.0': - resolution: {integrity: sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==} + '@typescript-eslint/project-service@8.41.0': + resolution: {integrity: sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.36.0': - resolution: {integrity: sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==} + '@typescript-eslint/scope-manager@8.41.0': + resolution: {integrity: sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.36.0': - resolution: {integrity: sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==} + '@typescript-eslint/tsconfig-utils@8.41.0': + resolution: {integrity: sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.36.0': - resolution: {integrity: sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg==} + '@typescript-eslint/type-utils@8.41.0': + resolution: {integrity: sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.36.0': - resolution: {integrity: sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==} + '@typescript-eslint/types@8.41.0': + resolution: {integrity: sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.36.0': - resolution: {integrity: sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==} + '@typescript-eslint/typescript-estree@8.41.0': + resolution: {integrity: sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.36.0': - resolution: {integrity: sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==} + '@typescript-eslint/utils@8.41.0': + resolution: {integrity: sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.36.0': - resolution: {integrity: sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==} + '@typescript-eslint/visitor-keys@8.41.0': + resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: @@ -435,8 +431,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.10.0: - resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==} + axios@1.11.0: + resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -537,8 +533,8 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dotenv@17.2.0: - resolution: {integrity: sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==} + dotenv@17.2.1: + resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -577,8 +573,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-prettier@10.1.5: - resolution: {integrity: sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' @@ -617,8 +613,8 @@ packages: '@typescript-eslint/parser': optional: true - eslint-plugin-prettier@5.5.1: - resolution: {integrity: sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==} + eslint-plugin-prettier@5.5.4: + resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' @@ -648,8 +644,8 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.30.1: - resolution: {integrity: sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==} + eslint@9.34.0: + resolution: {integrity: sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -716,8 +712,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -729,8 +725,8 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - form-data@4.0.3: - resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} fsevents@2.3.2: @@ -1106,13 +1102,13 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - playwright-core@1.53.2: - resolution: {integrity: sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==} + playwright-core@1.55.0: + resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==} engines: {node: '>=18'} hasBin: true - playwright@1.53.2: - resolution: {integrity: sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==} + playwright@1.55.0: + resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==} engines: {node: '>=18'} hasBin: true @@ -1276,8 +1272,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.8: - resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} + synckit@0.11.11: + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} to-regex-range@5.0.1: @@ -1369,32 +1365,32 @@ packages: snapshots: - '@emnapi/core@1.4.4': + '@emnapi/core@1.4.5': dependencies: - '@emnapi/wasi-threads': 1.0.3 + '@emnapi/wasi-threads': 1.0.4 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.4.4': + '@emnapi/runtime@1.4.5': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.0.3': + '@emnapi/wasi-threads@1.0.4': dependencies: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.30.1)': + '@eslint-community/eslint-utils@4.7.0(eslint@9.34.0)': dependencies: - eslint: 9.30.1 + eslint: 9.34.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} - '@eslint/compat@1.3.1(eslint@9.30.1)': + '@eslint/compat@1.3.2(eslint@9.34.0)': optionalDependencies: - eslint: 9.30.1 + eslint: 9.34.0 '@eslint/config-array@0.21.0': dependencies: @@ -1404,13 +1400,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.3.0': {} - - '@eslint/core@0.14.0': - dependencies: - '@types/json-schema': 7.0.15 + '@eslint/config-helpers@0.3.1': {} - '@eslint/core@0.15.1': + '@eslint/core@0.15.2': dependencies: '@types/json-schema': 7.0.15 @@ -1428,13 +1420,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.30.1': {} + '@eslint/js@9.34.0': {} '@eslint/object-schema@2.1.6': {} - '@eslint/plugin-kit@0.3.3': + '@eslint/plugin-kit@0.3.5': dependencies: - '@eslint/core': 0.15.1 + '@eslint/core': 0.15.2 levn: 0.4.1 '@faker-js/faker@9.9.0': {} @@ -1452,11 +1444,11 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@napi-rs/wasm-runtime@0.2.11': + '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.4.4 - '@emnapi/runtime': 1.4.4 - '@tybys/wasm-util': 0.9.0 + '@emnapi/core': 1.4.5 + '@emnapi/runtime': 1.4.5 + '@tybys/wasm-util': 0.10.0 optional: true '@nodelib/fs.scandir@2.1.5': @@ -1509,7 +1501,7 @@ snapshots: '@oxc-parser/binding-wasm32-wasi@0.74.0': dependencies: - '@napi-rs/wasm-runtime': 0.2.11 + '@napi-rs/wasm-runtime': 0.2.12 optional: true '@oxc-parser/binding-win32-arm64-msvc@0.74.0': @@ -1520,11 +1512,11 @@ snapshots: '@oxc-project/types@0.74.0': {} - '@pkgr/core@0.2.7': {} + '@pkgr/core@0.2.9': {} - '@playwright/test@1.53.2': + '@playwright/test@1.55.0': dependencies: - playwright: 1.53.2 + playwright: 1.55.0 '@prettier/plugin-oxc@0.0.4': dependencies: @@ -1534,7 +1526,7 @@ snapshots: '@scure/base@1.2.6': {} - '@tybys/wasm-util@0.9.0': + '@tybys/wasm-util@0.10.0': dependencies: tslib: 2.8.1 optional: true @@ -1547,27 +1539,27 @@ snapshots: '@types/lodash@4.17.20': {} - '@types/node@22.16.2': + '@types/node@22.18.0': dependencies: undici-types: 6.21.0 - '@types/pg@8.15.4': + '@types/pg@8.15.5': dependencies: - '@types/node': 22.16.2 + '@types/node': 22.18.0 pg-protocol: 1.10.3 pg-types: 2.2.0 '@types/totp-generator@0.0.8': {} - '@typescript-eslint/eslint-plugin@8.36.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1)(typescript@5.8.2))(eslint@9.30.1)(typescript@5.8.2)': + '@typescript-eslint/eslint-plugin@8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0)(typescript@5.8.2))(eslint@9.34.0)(typescript@5.8.2)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.36.0(eslint@9.30.1)(typescript@5.8.2) - '@typescript-eslint/scope-manager': 8.36.0 - '@typescript-eslint/type-utils': 8.36.0(eslint@9.30.1)(typescript@5.8.2) - '@typescript-eslint/utils': 8.36.0(eslint@9.30.1)(typescript@5.8.2) - '@typescript-eslint/visitor-keys': 8.36.0 - eslint: 9.30.1 + '@typescript-eslint/parser': 8.41.0(eslint@9.34.0)(typescript@5.8.2) + '@typescript-eslint/scope-manager': 8.41.0 + '@typescript-eslint/type-utils': 8.41.0(eslint@9.34.0)(typescript@5.8.2) + '@typescript-eslint/utils': 8.41.0(eslint@9.34.0)(typescript@5.8.2) + '@typescript-eslint/visitor-keys': 8.41.0 + eslint: 9.34.0 graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -1576,55 +1568,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.36.0(eslint@9.30.1)(typescript@5.8.2)': + '@typescript-eslint/parser@8.41.0(eslint@9.34.0)(typescript@5.8.2)': dependencies: - '@typescript-eslint/scope-manager': 8.36.0 - '@typescript-eslint/types': 8.36.0 - '@typescript-eslint/typescript-estree': 8.36.0(typescript@5.8.2) - '@typescript-eslint/visitor-keys': 8.36.0 + '@typescript-eslint/scope-manager': 8.41.0 + '@typescript-eslint/types': 8.41.0 + '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.8.2) + '@typescript-eslint/visitor-keys': 8.41.0 debug: 4.4.1 - eslint: 9.30.1 + eslint: 9.34.0 typescript: 5.8.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.36.0(typescript@5.8.2)': + '@typescript-eslint/project-service@8.41.0(typescript@5.8.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.36.0(typescript@5.8.2) - '@typescript-eslint/types': 8.36.0 + '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.8.2) + '@typescript-eslint/types': 8.41.0 debug: 4.4.1 typescript: 5.8.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.36.0': + '@typescript-eslint/scope-manager@8.41.0': dependencies: - '@typescript-eslint/types': 8.36.0 - '@typescript-eslint/visitor-keys': 8.36.0 + '@typescript-eslint/types': 8.41.0 + '@typescript-eslint/visitor-keys': 8.41.0 - '@typescript-eslint/tsconfig-utils@8.36.0(typescript@5.8.2)': + '@typescript-eslint/tsconfig-utils@8.41.0(typescript@5.8.2)': dependencies: typescript: 5.8.2 - '@typescript-eslint/type-utils@8.36.0(eslint@9.30.1)(typescript@5.8.2)': + '@typescript-eslint/type-utils@8.41.0(eslint@9.34.0)(typescript@5.8.2)': dependencies: - '@typescript-eslint/typescript-estree': 8.36.0(typescript@5.8.2) - '@typescript-eslint/utils': 8.36.0(eslint@9.30.1)(typescript@5.8.2) + '@typescript-eslint/types': 8.41.0 + '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.8.2) + '@typescript-eslint/utils': 8.41.0(eslint@9.34.0)(typescript@5.8.2) debug: 4.4.1 - eslint: 9.30.1 + eslint: 9.34.0 ts-api-utils: 2.1.0(typescript@5.8.2) typescript: 5.8.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.36.0': {} + '@typescript-eslint/types@8.41.0': {} - '@typescript-eslint/typescript-estree@8.36.0(typescript@5.8.2)': + '@typescript-eslint/typescript-estree@8.41.0(typescript@5.8.2)': dependencies: - '@typescript-eslint/project-service': 8.36.0(typescript@5.8.2) - '@typescript-eslint/tsconfig-utils': 8.36.0(typescript@5.8.2) - '@typescript-eslint/types': 8.36.0 - '@typescript-eslint/visitor-keys': 8.36.0 + '@typescript-eslint/project-service': 8.41.0(typescript@5.8.2) + '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.8.2) + '@typescript-eslint/types': 8.41.0 + '@typescript-eslint/visitor-keys': 8.41.0 debug: 4.4.1 fast-glob: 3.3.3 is-glob: 4.0.3 @@ -1635,20 +1628,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.36.0(eslint@9.30.1)(typescript@5.8.2)': + '@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.8.2)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.30.1) - '@typescript-eslint/scope-manager': 8.36.0 - '@typescript-eslint/types': 8.36.0 - '@typescript-eslint/typescript-estree': 8.36.0(typescript@5.8.2) - eslint: 9.30.1 + '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0) + '@typescript-eslint/scope-manager': 8.41.0 + '@typescript-eslint/types': 8.41.0 + '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.8.2) + eslint: 9.34.0 typescript: 5.8.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.36.0': + '@typescript-eslint/visitor-keys@8.41.0': dependencies: - '@typescript-eslint/types': 8.36.0 + '@typescript-eslint/types': 8.41.0 eslint-visitor-keys: 4.2.1 acorn-jsx@5.3.2(acorn@8.15.0): @@ -1728,10 +1721,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.10.0: + axios@1.11.0: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.3 + follow-redirects: 1.15.11 + form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -1839,7 +1832,7 @@ snapshots: dependencies: esutils: 2.0.3 - dotenv@17.2.0: {} + dotenv@17.2.1: {} dunder-proto@1.0.1: dependencies: @@ -1931,9 +1924,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.5(eslint@9.30.1): + eslint-config-prettier@10.1.8(eslint@9.34.0): dependencies: - eslint: 9.30.1 + eslint: 9.34.0 eslint-import-resolver-node@0.3.9: dependencies: @@ -1943,17 +1936,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.36.0(eslint@9.30.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.30.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.36.0(eslint@9.30.1)(typescript@5.8.2) - eslint: 9.30.1 + '@typescript-eslint/parser': 8.41.0(eslint@9.34.0)(typescript@5.8.2) + eslint: 9.34.0 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.36.0(eslint@9.30.1)(typescript@5.8.2))(eslint@9.30.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0)(typescript@5.8.2))(eslint@9.34.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -1962,9 +1955,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.30.1 + eslint: 9.34.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.36.0(eslint@9.30.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.30.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -1976,24 +1969,24 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.36.0(eslint@9.30.1)(typescript@5.8.2) + '@typescript-eslint/parser': 8.41.0(eslint@9.34.0)(typescript@5.8.2) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-prettier@5.5.1(eslint-config-prettier@10.1.5(eslint@9.30.1))(eslint@9.30.1)(prettier@3.6.2): + eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.34.0))(eslint@9.34.0)(prettier@3.6.2): dependencies: - eslint: 9.30.1 + eslint: 9.34.0 prettier: 3.6.2 prettier-linter-helpers: 1.0.0 - synckit: 0.11.8 + synckit: 0.11.11 optionalDependencies: - eslint-config-prettier: 10.1.5(eslint@9.30.1) + eslint-config-prettier: 10.1.8(eslint@9.34.0) - eslint-plugin-simple-import-sort@12.1.1(eslint@9.30.1): + eslint-plugin-simple-import-sort@12.1.1(eslint@9.34.0): dependencies: - eslint: 9.30.1 + eslint: 9.34.0 eslint-scope@8.4.0: dependencies: @@ -2004,16 +1997,16 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.30.1: + eslint@9.34.0: dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.30.1) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.21.0 - '@eslint/config-helpers': 0.3.0 - '@eslint/core': 0.14.0 + '@eslint/config-helpers': 0.3.1 + '@eslint/core': 0.15.2 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.30.1 - '@eslint/plugin-kit': 0.3.3 + '@eslint/js': 9.34.0 + '@eslint/plugin-kit': 0.3.5 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -2102,13 +2095,13 @@ snapshots: flatted@3.3.3: {} - follow-redirects@1.15.9: {} + follow-redirects@1.15.11: {} for-each@0.3.5: dependencies: is-callable: 1.2.7 - form-data@4.0.3: + form-data@4.0.4: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -2510,11 +2503,11 @@ snapshots: picomatch@2.3.1: {} - playwright-core@1.53.2: {} + playwright-core@1.55.0: {} - playwright@1.53.2: + playwright@1.55.0: dependencies: - playwright-core: 1.53.2 + playwright-core: 1.55.0 optionalDependencies: fsevents: 2.3.2 @@ -2697,9 +2690,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.8: + synckit@0.11.11: dependencies: - '@pkgr/core': 0.2.7 + '@pkgr/core': 0.2.9 to-regex-range@5.0.1: dependencies: diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index e2a5cb1257..59026a1998 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -11,7 +11,7 @@ import { enableEmailMFA } from '../utils/controllers/mfa/enableEmail'; import { enableTOTP } from '../utils/controllers/mfa/enableTOTP'; import { changePassword, changePasswordByAdmin } from '../utils/controllers/profile'; import { disableUser } from '../utils/controllers/toggleUserState'; -import { dockerDown, dockerRestart } from '../utils/docker'; +import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; import { waitForRoute } from '../utils/waitForRoute'; @@ -23,8 +23,6 @@ test.describe('Test user authentication', () => { testUser = { ...testUserTemplate, username: 'test' }; }); - test.afterAll(() => dockerDown()); - test('Basic auth with default admin', async ({ page }) => { await waitForBase(page); await loginBasic(page, defaultUserAdmin); @@ -122,8 +120,6 @@ test.describe('Test password change', () => { testUser = { ...testUserTemplate, username: 'test' }; }); - test.afterAll(() => dockerDown()); - test('Change user password', async ({ page, browser }) => { await waitForBase(page); await createUser(browser, testUser); diff --git a/e2e/tests/authenticationKeys.spec.ts b/e2e/tests/authenticationKeys.spec.ts index c6d5a244b3..a9477f0110 100644 --- a/e2e/tests/authenticationKeys.spec.ts +++ b/e2e/tests/authenticationKeys.spec.ts @@ -4,7 +4,7 @@ import { defaultUserAdmin, routes, testUserTemplate } from '../config'; import { AuthenticationKeyType, User } from '../types'; import { apiCreateUser, apiGetUserAuthKeys } from '../utils/api/users'; import { loginBasic } from '../utils/controllers/login'; -import { dockerDown, dockerRestart } from '../utils/docker'; +import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; import { waitForPromise } from '../utils/waitForPromise'; import { waitForRoute } from '../utils/waitForRoute'; @@ -55,10 +55,6 @@ QW+7CejaY/Essu7DN6HwqwXbipny63b8ct1UXjG02S+Q await waitForRoute(page, url); }); - test.afterAll(() => { - dockerDown(); - }); - test('Add authentication key (SSH)', async ({ page }) => { await page.getByTestId('add-authentication-key-button').click(); await page.locator('#add-authentication-key-modal').waitFor({ diff --git a/e2e/tests/enrollment.spec.ts b/e2e/tests/enrollment.spec.ts index 4252301a31..6d3ea8082f 100644 --- a/e2e/tests/enrollment.spec.ts +++ b/e2e/tests/enrollment.spec.ts @@ -15,7 +15,7 @@ import { import { loginBasic } from '../utils/controllers/login'; import { disableUser, enableUser } from '../utils/controllers/toggleUserState'; import { createNetwork } from '../utils/controllers/vpn/createNetwork'; -import { dockerDown, dockerRestart } from '../utils/docker'; +import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; import { waitForPromise } from '../utils/waitForPromise'; @@ -37,10 +37,6 @@ test.describe('Create user with enrollment enabled', () => { await createNetwork(browser, testNetwork); }); - test.afterAll(() => { - dockerDown(); - }); - test('Try to complete enrollment with disabled user', async ({ page, browser }) => { expect(token).toBeDefined(); await waitForBase(page); diff --git a/e2e/tests/externalopenid.spec.ts b/e2e/tests/externalopenid.spec.ts index 1f09ea0761..5d3d2a5fc4 100644 --- a/e2e/tests/externalopenid.spec.ts +++ b/e2e/tests/externalopenid.spec.ts @@ -9,7 +9,7 @@ import { copyOpenIdClientIdAndSecret } from '../utils/controllers/openid/copyCli import { createExternalProvider } from '../utils/controllers/openid/createExternalProvider'; import { CreateOpenIdClient } from '../utils/controllers/openid/createOpenIdClient'; import { createNetwork } from '../utils/controllers/vpn/createNetwork'; -import { dockerDown, dockerRestart } from '../utils/docker'; +import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; import { waitForPromise } from '../utils/waitForPromise'; import { waitForRoute } from '../utils/waitForRoute'; @@ -50,10 +50,6 @@ test.describe('External OIDC.', () => { await context.close(); }); - test.afterAll(() => { - dockerDown(); - }); - test('Login through external oidc.', async ({ page }) => { expect(client.clientID).toBeDefined(); expect(client.clientSecret).toBeDefined(); diff --git a/e2e/tests/externalopenidmfa.spec.ts b/e2e/tests/externalopenidmfa.spec.ts index b1c82ed31d..579fc2381e 100644 --- a/e2e/tests/externalopenidmfa.spec.ts +++ b/e2e/tests/externalopenidmfa.spec.ts @@ -10,7 +10,7 @@ import { createExternalProvider } from '../utils/controllers/openid/createExtern import { CreateOpenIdClient } from '../utils/controllers/openid/createOpenIdClient'; import { createDevice } from '../utils/controllers/vpn/createDevice'; import { createNetwork } from '../utils/controllers/vpn/createNetwork'; -import { dockerDown, dockerRestart } from '../utils/docker'; +import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; import { waitForPromise } from '../utils/waitForPromise'; @@ -48,10 +48,6 @@ test.describe('External OIDC.', () => { await context.close(); }); - test.afterAll(() => { - dockerDown(); - }); - test('Complete client MFA through external OpenID', async ({ page, browser }) => { await waitForBase(page); const mfaStartUrl = `${testsConfig.ENROLLMENT_URL}/api/v1/client-mfa/start`; diff --git a/e2e/tests/groups.spec.ts b/e2e/tests/groups.spec.ts index 91f8b47b82..4cbb2acf52 100644 --- a/e2e/tests/groups.spec.ts +++ b/e2e/tests/groups.spec.ts @@ -3,15 +3,13 @@ import { expect, test } from '@playwright/test'; import { routes, testUserTemplate } from '../config'; import { createUser } from '../utils/controllers/createUser'; import { loginBasic } from '../utils/controllers/login'; -import { dockerDown, dockerRestart } from '../utils/docker'; +import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; import { waitForRoute } from '../utils/waitForRoute'; test.describe('Test groups', () => { test.beforeEach(() => dockerRestart()); - test.afterAll(() => dockerDown()); - test('Add user to admin group', async ({ page, browser }) => { const testUser = { ...testUserTemplate, username: 'test' }; await waitForBase(page); diff --git a/e2e/tests/openid.spec.ts b/e2e/tests/openid.spec.ts index e0c59c697c..894323789c 100644 --- a/e2e/tests/openid.spec.ts +++ b/e2e/tests/openid.spec.ts @@ -8,7 +8,7 @@ import { logout } from '../utils/controllers/logout'; import { enableTOTP } from '../utils/controllers/mfa/enableTOTP'; import { copyOpenIdClientId } from '../utils/controllers/openid/copyClientId'; import { CreateOpenIdClient } from '../utils/controllers/openid/createOpenIdClient'; -import { dockerDown, dockerRestart } from '../utils/docker'; +import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; import { waitForPromise } from '../utils/waitForPromise'; import { waitForRoute } from '../utils/waitForRoute'; @@ -36,10 +36,6 @@ test.describe('Authorize OpenID client.', () => { context.close(); }); - test.afterAll(() => { - dockerDown(); - }); - test('Authorize when session is active.', async ({ page }) => { expect(client.clientID).toBeDefined(); await waitForBase(page); diff --git a/e2e/tests/passwordReset.spec.ts b/e2e/tests/passwordReset.spec.ts index 778e266824..02c7c44450 100644 --- a/e2e/tests/passwordReset.spec.ts +++ b/e2e/tests/passwordReset.spec.ts @@ -12,7 +12,7 @@ import { } from '../utils/controllers/passwordReset'; import { disableUser } from '../utils/controllers/toggleUserState'; import { getPasswordResetToken } from '../utils/db/getPasswordResetToken'; -import { dockerDown, dockerRestart } from '../utils/docker'; +import { dockerRestart } from '../utils/docker'; import { waitForBase } from '../utils/waitForBase'; import { waitForPromise } from '../utils/waitForPromise'; @@ -26,10 +26,6 @@ test.describe('Reset password', () => { await createUser(browser, user); }); - test.afterAll(() => { - dockerDown(); - }); - test('Reset user password', async ({ page }) => { await waitForBase(page); await page.goto(testsConfig.ENROLLMENT_URL); diff --git a/e2e/tests/vpn/device.spec.ts b/e2e/tests/vpn/device.spec.ts index 0454d9e7ab..82af8cf635 100644 --- a/e2e/tests/vpn/device.spec.ts +++ b/e2e/tests/vpn/device.spec.ts @@ -6,7 +6,7 @@ import { apiCreateUser, apiGetUserProfile } from '../../utils/api/users'; import { loginBasic } from '../../utils/controllers/login'; import { createDevice } from '../../utils/controllers/vpn/createDevice'; import { createNetwork } from '../../utils/controllers/vpn/createNetwork'; -import { dockerDown, dockerRestart } from '../../utils/docker'; +import { dockerRestart } from '../../utils/docker'; import { waitForBase } from '../../utils/waitForBase'; const testKeys = { @@ -38,8 +38,6 @@ test.describe('Add user device', () => { await context.close(); }); - test.afterAll(() => dockerDown()); - test('Add test user device with generate', async ({ page, browser }) => { await waitForBase(page); await createDevice(browser, testUser, { diff --git a/e2e/tests/vpn/networkdevice.spec.ts b/e2e/tests/vpn/networkdevice.spec.ts index 1037004ace..0b037fbf4a 100644 --- a/e2e/tests/vpn/networkdevice.spec.ts +++ b/e2e/tests/vpn/networkdevice.spec.ts @@ -12,7 +12,7 @@ import { getDeviceRow, startNetworkDeviceEnrollment, } from '../../utils/controllers/vpn/createNetworkDevice'; -import { dockerDown, dockerRestart } from '../../utils/docker'; +import { dockerRestart } from '../../utils/docker'; import { waitForBase } from '../../utils/waitForBase'; import { waitForRoute } from '../../utils/waitForRoute'; @@ -45,8 +45,6 @@ test.describe('Network devices', () => { await context.close(); }); - test.afterAll(() => dockerDown()); - test('Network devices CRUD and actions', async ({ page, browser }) => { const deviceName = 'test'; const deviceDesc = 'test device description'; diff --git a/e2e/tests/vpn/wizard.spec.ts b/e2e/tests/vpn/wizard.spec.ts index f2023dd5ad..1f9f8c48cd 100644 --- a/e2e/tests/vpn/wizard.spec.ts +++ b/e2e/tests/vpn/wizard.spec.ts @@ -12,7 +12,7 @@ import { } from '../../utils/api/users'; import { loginBasic } from '../../utils/controllers/login'; import { createNetwork } from '../../utils/controllers/vpn/createNetwork'; -import { dockerDown, dockerRestart } from '../../utils/docker'; +import { dockerRestart } from '../../utils/docker'; import { waitForBase } from '../../utils/waitForBase'; import { waitForPromise } from '../../utils/waitForPromise'; import { waitForRoute } from '../../utils/waitForRoute'; @@ -26,10 +26,6 @@ test.describe('Setup VPN (wizard) ', () => { dockerRestart(); }); - test.afterAll(() => { - dockerDown(); - }); - test('Wizard Import', async ({ page }) => { await waitForBase(page); // create users to map devices to; diff --git a/e2e/utils/controllers/enrollment.ts b/e2e/utils/controllers/enrollment.ts index f0ea6c2bc9..3ccacca157 100644 --- a/e2e/utils/controllers/enrollment.ts +++ b/e2e/utils/controllers/enrollment.ts @@ -2,7 +2,6 @@ import { Browser, expect, Page } from '@playwright/test'; import { defaultUserAdmin, routes } from '../../config'; import { User } from '../../types'; -import { getPageClipboard } from '../getPageClipboard'; import { waitForBase } from '../waitForBase'; import { waitForPromise } from '../waitForPromise'; import { loginBasic } from './login'; @@ -43,8 +42,9 @@ export const createUserEnrollment = async ( waitForPromise(2000); // Copy to clipboard const tokenStep = modalElement.locator('#enrollment-token-step'); - await tokenStep.getByTestId('copy-enrollment-token').click(); - const token = await getPageClipboard(page); + const tokenDiv = tokenStep.locator('.copy-field.spacer').nth(1); // field with token + const tokenP = tokenDiv.locator('p.display-element'); + const token = await tokenP.textContent(); expect(token.length).toBeGreaterThan(0); // close modal await modalElement.locator('.controls button.cancel').click(); diff --git a/e2e/utils/controllers/vpn/createNetwork.ts b/e2e/utils/controllers/vpn/createNetwork.ts index b17a059872..da01269199 100644 --- a/e2e/utils/controllers/vpn/createNetwork.ts +++ b/e2e/utils/controllers/vpn/createNetwork.ts @@ -25,7 +25,25 @@ export const createNetwork = async (browser: Browser, network: NetworkForm) => { // select location MFA mode if (network.location_mfa_mode) { const mfaModeSelect = page.locator('div.location-mfa-mode-select'); - const mfaMode = mfaModeSelect.locator(`div.${network.location_mfa_mode}`); + let mode: number; // TODO: do it better + switch (network.location_mfa_mode) { + case 'none': + mode = 0; + break; + case 'internal': + mode = 1; + break; + case 'external': + mode = 2; + break; + default: + mode = 0; + break; + } + // 0 - do not enforce mfa + // 1 - internal mfa + // 2 - external mfa + const mfaMode = mfaModeSelect.locator(`div.location-mfa-mode`).nth(mode); await mfaMode.click(); } diff --git a/e2e/utils/docker.ts b/e2e/utils/docker.ts index cda36060c4..966886f07d 100644 --- a/e2e/utils/docker.ts +++ b/e2e/utils/docker.ts @@ -4,32 +4,34 @@ import path from 'path'; const defguardPath = __dirname.split('e2e')[0]; const dockerFilePath = path.resolve(defguardPath, 'docker-compose.e2e.yaml'); +const dockerCompose = `docker compose -f ${dockerFilePath}`; -// Startups defguard stack with docker compose +// Start Defguard stack with docker compose. export const dockerUp = () => { - const command = `docker compose -f ${dockerFilePath.toString()} up -d`; + const command = `${dockerCompose} up --wait`; execSync(command); -}; - -export const dockerDown = () => { - const command = `docker compose -f ${dockerFilePath.toString()} down`; - if (dockerCheckContainers()) { - execSync(command); - } + // NOTE: After waiting, sleep for 3 seconds to let Defguard Core apply migrations. + const wait_for_db = `${dockerCompose} exec db sh -c 'until pg_isready; do sleep 1; done; sleep 3'`; + execSync(wait_for_db); + const create_snapshot = `${dockerCompose} exec db pg_dump -U defguard -Fc -f /tmp/defguard_backup.dump defguard`; + execSync(create_snapshot); }; export const dockerCheckContainers = (): boolean => { - const command = `docker ps -q`; + const command = `${dockerCompose} ps -q`; const containers = execSync(command).toString().trim(); return Boolean(containers.length); }; export const dockerRestart = () => { - dockerDown(); - dockerUp(); -}; - -export const dockerStartup = () => { - dockerDown(); - dockerUp(); + if (!dockerCheckContainers()) { + dockerUp(); + } else { + const restore = `${dockerCompose} exec db pg_restore --clean -U defguard -d defguard /tmp/defguard_backup.dump`; + execSync(restore); + const restart = `${dockerCompose} restart db`; + execSync(restart); + const wait_for_db = `${dockerCompose} exec db sh -c 'until pg_isready; do sleep 1; done'`; + execSync(wait_for_db); + } }; diff --git a/flake.lock b/flake.lock index a874e42a18..abf59ab737 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1751984180, - "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", + "lastModified": 1757347588, + "narHash": "sha256-tLdkkC6XnsY9EOZW9TlpesTclELy8W7lL2ClL+nma8o=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", + "rev": "b599843bad24621dcaa5ab60dac98f9b0eb1cabe", "type": "github" }, "original": { @@ -48,11 +48,11 @@ ] }, "locked": { - "lastModified": 1752461263, - "narHash": "sha256-f4XVgqkWF1vSzPbOG5xvi4aAd/n1GwSNsji3mLMFwYQ=", + "lastModified": 1757471515, + "narHash": "sha256-0+rSzNsYindDWjO9VVULKGjXlPsQV6IDjRU5G3SwI9U=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "9cc51d100d24fb7ea13a0bee1480ee84fa12a0ad", + "rev": "aecf31120156fe47a7d1992aa814052910178fca", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 92e7daa925..f2e6a984c1 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,8 @@ buf # e2e playwright + # release assets verification + cosign ]; # Specify the rust-src path (many editors rely on this) diff --git a/images/ami/core.pkr.hcl b/images/ami/core.pkr.hcl index 8bb7d90fdd..477cfea388 100644 --- a/images/ami/core.pkr.hcl +++ b/images/ami/core.pkr.hcl @@ -27,14 +27,14 @@ source "amazon-ebs" "defguard-core" { region = var.region source_ami_filter { filters = { - name = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*" + name = "debian-13-amd64-*" root-device-type = "ebs" virtualization-type = "hvm" } most_recent = true - owners = ["099720109477"] + owners = ["136693071363"] } - ssh_username = "ubuntu" + ssh_username = "admin" } build { @@ -53,7 +53,7 @@ build { } provisioner "shell" { - inline = ["rm /home/ubuntu/.ssh/authorized_keys"] + inline = ["rm /home/admin/.ssh/authorized_keys"] } provisioner "shell" { diff --git a/justfile b/justfile index bc75458dd6..d287c66dd4 100644 --- a/justfile +++ b/justfile @@ -33,4 +33,4 @@ migrate: # update sqlx query data query-data: - cargo sqlx prepare --workspace -- --all-targets --all --tests + cargo sqlx prepare --workspace -- --all-targets --tests diff --git a/migrations/20250731063659_biometric_auth.down.sql b/migrations/20250731063659_biometric_auth.down.sql new file mode 100644 index 0000000000..a722c24c32 --- /dev/null +++ b/migrations/20250731063659_biometric_auth.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS biometric_auth; diff --git a/migrations/20250731063659_biometric_auth.up.sql b/migrations/20250731063659_biometric_auth.up.sql new file mode 100644 index 0000000000..885371af1d --- /dev/null +++ b/migrations/20250731063659_biometric_auth.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE biometric_auth ( + id bigserial PRIMARY KEY, + pub_key text NOT NULL, + device_id bigint NOT NULL, + FOREIGN KEY(device_id) REFERENCES "device"(id) ON DELETE CASCADE, + CONSTRAINT biometric_auth_device UNIQUE (device_id) +); diff --git a/migrations/20250812112132_add_jumpcloud_key.down.sql b/migrations/20250812112132_add_jumpcloud_key.down.sql new file mode 100644 index 0000000000..60fd3e6a69 --- /dev/null +++ b/migrations/20250812112132_add_jumpcloud_key.down.sql @@ -0,0 +1 @@ +ALTER TABLE openidprovider DROP COLUMN jumpcloud_api_key; diff --git a/migrations/20250812112132_add_jumpcloud_key.up.sql b/migrations/20250812112132_add_jumpcloud_key.up.sql new file mode 100644 index 0000000000..78c09241c5 --- /dev/null +++ b/migrations/20250812112132_add_jumpcloud_key.up.sql @@ -0,0 +1 @@ +ALTER TABLE openidprovider ADD COLUMN jumpcloud_api_key TEXT DEFAULT NULL; diff --git a/proto b/proto index b9f24ac413..883487df67 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit b9f24ac41326bffe4c7e72019ccc8a785f6bd343 +Subproject commit 883487df67d90fd14fae900737cd8b5ea6c10de3 diff --git a/web/.nvmrc b/web/.nvmrc index 19f23bcebc..53a256a2ea 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -v24.4 +v24.7 diff --git a/web/biome.json b/web/biome.json index 47dc2cac51..2bcbfd1d70 100644 --- a/web/biome.json +++ b/web/biome.json @@ -1,9 +1,9 @@ { - "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.2/schema.json", "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, "files": { "ignoreUnknown": false, - "includes": ["src/**", "!src/i18n/**", "!**/svg/**"] + "includes": ["src/**", "!src/i18n", "!**/svg"] }, "formatter": { "enabled": true, @@ -16,8 +16,7 @@ "bracketSameLine": false, "bracketSpacing": true, "expand": "auto", - "useEditorconfig": true, - "includes": ["./src/**"] + "useEditorconfig": true }, "linter": { "enabled": true, @@ -34,7 +33,8 @@ "noUnusedVariables": "error", "useExhaustiveDependencies": "error", "useHookAtTopLevel": "error", - "useJsxKeyInIterable": "error" + "useJsxKeyInIterable": "error", + "useUniqueElementIds": "off" }, "security": { "noDangerouslySetInnerHtmlWithChildren": "error" }, "style": { @@ -54,8 +54,7 @@ "noUnsafeDeclarationMerging": "error", "noArrayIndexKey": "off" } - }, - "includes": ["src/**"] + } }, "javascript": { "formatter": { @@ -64,14 +63,22 @@ "trailingCommas": "all", "semicolons": "always", "arrowParentheses": "always", - "bracketSameLine": false, "quoteStyle": "single", "attributePosition": "auto", + "bracketSameLine": false, "bracketSpacing": true } }, "assist": { "enabled": true, "actions": { "source": { "organizeImports": "on" } } - } + }, + "overrides": [ + { + "includes": ["src/shared/links.ts"], + "formatter": { + "enabled": false + } + } + ] } diff --git a/web/package.json b/web/package.json index 04fc499c22..19a73a2751 100644 --- a/web/package.json +++ b/web/package.json @@ -2,14 +2,15 @@ "name": "web", "type": "module", "scripts": { + "preview": "pnpm run build && pnpm run vite preview", "dev": "concurrently \"pnpm run vite\" \"pnpm run typesafe-i18n\"", "build": "pnpm run typecheck && vite build", "serve": "vite preview", "typecheck": "tsc --project ./tsconfig.app.json", "generate-translation-types": "typesafe-i18n --no-watch", - "fix": "biome check --fix && prettier src/**/*.scss -w --log-level silent", - "lint": "biome lint && pnpm run typecheck && prettier src/**/*.scss --check --log-level error", - "lint-ci": "biome ci && pnpm run typecheck && prettier src/**/*.scss --check --log-level error", + "fix": "biome check ./src --write --assist-enabled=true && prettier src/**/*.scss -w --log-level silent", + "fix-unsafe": "biome check ./src --write --unsafe --assist-enabled=true && prettier src/**/*.scss -w --log-level silent", + "lint": "biome check ./src --error-on-warnings --formatter-enabled=true --enforce-assist=true --linter-enabled=true && pnpm run typecheck && prettier src/**/*.scss --check --log-level error", "typesafe-i18n": "typesafe-i18n", "vite": "vite", "prettier": "prettier", @@ -42,31 +43,30 @@ ] }, "dependencies": { - "@floating-ui/react": "^0.27.13", + "@floating-ui/react": "^0.27.16", "@github/webauthn-json": "^2.1.1", - "@hookform/resolvers": "^5.1.1", + "@hookform/resolvers": "^5.2.1", "@react-hook/resize-observer": "^2.0.2", "@react-rxjs/core": "^0.10.8", "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", - "@tanstack/query-core": "^5.83.0", - "@tanstack/react-query": "^5.83.0", + "@tanstack/query-core": "^5.87.1", + "@tanstack/react-query": "^5.87.1", "@tanstack/react-virtual": "3.13.12", "@tanstack/virtual-core": "3.13.12", "@use-gesture/react": "^10.3.1", - "axios": "^1.11.0", + "axios": "^1.12.0", "byte-size": "^9.0.1", "classnames": "^2.5.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "dayjs": "^1.11.13", + "dayjs": "^1.11.18", "deepmerge-ts": "^7.1.5", "detect-browser": "^5.3.0", "dice-coefficient": "^2.1.1", "events": "^3.3.0", "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", - "framer-motion": "^12.23.7", "fuse.js": "^7.1.0", "get-text-width": "^1.0.3", "hex-rgb": "^5.0.0", @@ -74,32 +74,32 @@ "humanize-duration": "^3.33.0", "ipaddr.js": "^2.2.0", "itertools": "^2.4.1", - "js-base64": "^3.7.7", + "js-base64": "^3.7.8", "lodash-es": "^4.17.21", "merge-refs": "^2.0.0", "millify": "^6.1.0", + "motion": "^12.23.12", "numbro": "^2.5.0", "qrcode": "^1.5.4", "qs": "^6.14.0", "radash": "^12.1.1", - "react": "^18.3.1", + "react": "^19.1.1", "react-click-away-listener": "^2.4.0", - "react-datepicker": "^8.4.0", - "react-dom": "^18.3.1", - "react-hook-form": "^7.61.0", + "react-datepicker": "^8.7.0", + "react-dom": "^19.1.1", + "react-hook-form": "^7.62.0", "react-idle-timer": "^5.7.2", "react-intersection-observer": "^9.16.0", - "react-is": "^19.1.0", + "react-is": "^19.1.1", "react-loading-skeleton": "^3.5.0", "react-markdown": "^10.1.0", "react-qr-code": "^2.0.18", - "react-resize-detector": "^12.1.0", + "react-resize-detector": "^12.3.0", "react-router": "^6.30.1", "react-router-dom": "^6.30.1", "react-tracked": "^2.0.1", "react-virtualized-auto-sizer": "^1.0.26", - "react-window": "^1.8.11", - "recharts": "^3.1.0", + "recharts": "^3.2.0", "rehype-external-links": "^3.0.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -109,38 +109,37 @@ "typesafe-i18n": "^5.26.2", "use-breakpoint": "^4.0.6", "zod": "^3.25.76", - "zustand": "^5.0.6" + "zustand": "^5.0.8" }, "devDependencies": { - "@babel/core": "^7.28.0", - "@biomejs/biome": "2.1.1", + "@babel/core": "^7.28.4", + "@biomejs/biome": "2.2.2", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "@hookform/devtools": "^4.4.0", - "@tanstack/react-query-devtools": "^5.83.0", + "@tanstack/react-query-devtools": "^5.87.3", "@types/byte-size": "^8.1.2", "@types/file-saver": "^2.0.7", "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", - "@types/node": "^24.1.0", + "@types/node": "^24.3.1", "@types/qs": "^6.14.0", - "@types/react": "^18.3.23", - "@types/react-dom": "^18.3.7", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@types/react-router-dom": "^5.3.3", - "@types/react-window": "^1.8.8", - "@vitejs/plugin-react-swc": "^3.11.0", + "@vitejs/plugin-react-swc": "^4.0.1", "autoprefixer": "^10.4.21", - "concurrently": "^9.2.0", - "dotenv": "^17.2.0", - "esbuild": "^0.25.8", - "globals": "^16.3.0", + "concurrently": "^9.2.1", + "dotenv": "^17.2.2", + "esbuild": "^0.25.9", + "globals": "^16.4.0", "postcss": "^8.5.6", "prettier": "^3.6.2", "sass": "~1.70.0", "standard-version": "^9.5.0", "type-fest": "^4.41.0", - "typescript": "~5.8.3", - "vite": "^7.0.6", + "typescript": "~5.9.2", + "vite": "^7.1.5", "vite-plugin-package-version": "^1.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index bae5c4a083..fb0bd13deb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -9,20 +9,20 @@ importers: .: dependencies: '@floating-ui/react': - specifier: ^0.27.13 - version: 0.27.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^0.27.16 + version: 0.27.16(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@github/webauthn-json': specifier: ^2.1.1 version: 2.1.1 '@hookform/resolvers': - specifier: ^5.1.1 - version: 5.1.1(react-hook-form@7.61.0(react@18.3.1)) + specifier: ^5.2.1 + version: 5.2.1(react-hook-form@7.62.0(react@19.1.1)) '@react-hook/resize-observer': specifier: ^2.0.2 - version: 2.0.2(react@18.3.1) + version: 2.0.2(react@19.1.1) '@react-rxjs/core': specifier: ^0.10.8 - version: 0.10.8(react@18.3.1)(rxjs@7.8.2) + version: 0.10.8(react@19.1.1)(rxjs@7.8.2) '@stablelib/base64': specifier: ^2.0.1 version: 2.0.1 @@ -30,23 +30,23 @@ importers: specifier: ^2.0.1 version: 2.0.1 '@tanstack/query-core': - specifier: ^5.83.0 - version: 5.83.0 + specifier: ^5.87.1 + version: 5.87.1 '@tanstack/react-query': - specifier: ^5.83.0 - version: 5.83.0(react@18.3.1) + specifier: ^5.87.1 + version: 5.87.1(react@19.1.1) '@tanstack/react-virtual': specifier: 3.13.12 - version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.13.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@tanstack/virtual-core': specifier: 3.13.12 version: 3.13.12 '@use-gesture/react': specifier: ^10.3.1 - version: 10.3.1(react@18.3.1) + version: 10.3.1(react@19.1.1) axios: - specifier: ^1.11.0 - version: 1.11.0 + specifier: ^1.12.0 + version: 1.12.0 byte-size: specifier: ^9.0.1 version: 9.0.1 @@ -60,8 +60,8 @@ importers: specifier: ^4.1.0 version: 4.1.0 dayjs: - specifier: ^1.11.13 - version: 1.11.13 + specifier: ^1.11.18 + version: 1.11.18 deepmerge-ts: specifier: ^7.1.5 version: 7.1.5 @@ -80,9 +80,6 @@ importers: file-saver: specifier: ^2.0.5 version: 2.0.5 - framer-motion: - specifier: ^12.23.7 - version: 12.23.7(@emotion/is-prop-valid@1.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fuse.js: specifier: ^7.1.0 version: 7.1.0 @@ -94,7 +91,7 @@ importers: version: 5.0.0 html-react-parser: specifier: ^5.2.6 - version: 5.2.6(@types/react@18.3.23)(react@18.3.1) + version: 5.2.6(@types/react@19.1.12)(react@19.1.1) humanize-duration: specifier: ^3.33.0 version: 3.33.0 @@ -105,17 +102,20 @@ importers: specifier: ^2.4.1 version: 2.4.1 js-base64: - specifier: ^3.7.7 - version: 3.7.7 + specifier: ^3.7.8 + version: 3.7.8 lodash-es: specifier: ^4.17.21 version: 4.17.21 merge-refs: specifier: ^2.0.0 - version: 2.0.0(@types/react@18.3.23) + version: 2.0.0(@types/react@19.1.12) millify: specifier: ^6.1.0 version: 6.1.0 + motion: + specifier: ^12.23.12 + version: 12.23.12(@emotion/is-prop-valid@1.4.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) numbro: specifier: ^2.5.0 version: 2.5.0 @@ -129,59 +129,56 @@ importers: specifier: ^12.1.1 version: 12.1.1 react: - specifier: ^18.3.1 - version: 18.3.1 + specifier: ^19.1.1 + version: 19.1.1 react-click-away-listener: specifier: ^2.4.0 - version: 2.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.4.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-datepicker: - specifier: ^8.4.0 - version: 8.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^8.7.0 + version: 8.7.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) + specifier: ^19.1.1 + version: 19.1.1(react@19.1.1) react-hook-form: - specifier: ^7.61.0 - version: 7.61.0(react@18.3.1) + specifier: ^7.62.0 + version: 7.62.0(react@19.1.1) react-idle-timer: specifier: ^5.7.2 - version: 5.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 5.7.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-intersection-observer: specifier: ^9.16.0 - version: 9.16.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 9.16.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-is: - specifier: ^19.1.0 - version: 19.1.0 + specifier: ^19.1.1 + version: 19.1.1 react-loading-skeleton: specifier: ^3.5.0 - version: 3.5.0(react@18.3.1) + version: 3.5.0(react@19.1.1) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@18.3.23)(react@18.3.1) + version: 10.1.0(@types/react@19.1.12)(react@19.1.1) react-qr-code: specifier: ^2.0.18 - version: 2.0.18(react@18.3.1) + version: 2.0.18(react@19.1.1) react-resize-detector: - specifier: ^12.1.0 - version: 12.1.0(react@18.3.1) + specifier: ^12.3.0 + version: 12.3.0(react@19.1.1) react-router: specifier: ^6.30.1 - version: 6.30.1(react@18.3.1) + version: 6.30.1(react@19.1.1) react-router-dom: specifier: ^6.30.1 - version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.30.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-tracked: specifier: ^2.0.1 - version: 2.0.1(react@18.3.1)(scheduler@0.26.0) + version: 2.0.1(react@19.1.1)(scheduler@0.26.0) react-virtualized-auto-sizer: specifier: ^1.0.26 - version: 1.0.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-window: - specifier: ^1.8.11 - version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.26(react-dom@19.1.1(react@19.1.1))(react@19.1.1) recharts: - specifier: ^3.1.0 - version: 3.1.0(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@19.1.0)(react@18.3.1)(redux@5.0.1) + specifier: ^3.2.0 + version: 3.2.0(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react-is@19.1.1)(react@19.1.1)(redux@5.0.1) rehype-external-links: specifier: ^3.0.0 version: 3.0.0 @@ -202,23 +199,23 @@ importers: version: 1.2.4 typesafe-i18n: specifier: ^5.26.2 - version: 5.26.2(typescript@5.8.3) + version: 5.26.2(typescript@5.9.2) use-breakpoint: specifier: ^4.0.6 - version: 4.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) zod: specifier: ^3.25.76 version: 3.25.76 zustand: - specifier: ^5.0.6 - version: 5.0.6(@types/react@18.3.23)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1)) + specifier: ^5.0.8 + version: 5.0.8(@types/react@19.1.12)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)) devDependencies: '@babel/core': - specifier: ^7.28.0 - version: 7.28.0 + specifier: ^7.28.4 + version: 7.28.4 '@biomejs/biome': - specifier: 2.1.1 - version: 2.1.1 + specifier: 2.2.2 + version: 2.2.2 '@csstools/css-parser-algorithms': specifier: ^3.0.5 version: 3.0.5(@csstools/css-tokenizer@3.0.4) @@ -227,10 +224,10 @@ importers: version: 3.0.4 '@hookform/devtools': specifier: ^4.4.0 - version: 4.4.0(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.4.0(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@tanstack/react-query-devtools': - specifier: ^5.83.0 - version: 5.83.0(@tanstack/react-query@5.83.0(react@18.3.1))(react@18.3.1) + specifier: ^5.87.3 + version: 5.87.3(@tanstack/react-query@5.87.1(react@19.1.1))(react@19.1.1) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -244,41 +241,38 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/node': - specifier: ^24.1.0 - version: 24.1.0 + specifier: ^24.3.1 + version: 24.3.1 '@types/qs': specifier: ^6.14.0 version: 6.14.0 '@types/react': - specifier: ^18.3.23 - version: 18.3.23 + specifier: ^19.1.12 + version: 19.1.12 '@types/react-dom': - specifier: ^18.3.7 - version: 18.3.7(@types/react@18.3.23) + specifier: ^19.1.9 + version: 19.1.9(@types/react@19.1.12) '@types/react-router-dom': specifier: ^5.3.3 version: 5.3.3 - '@types/react-window': - specifier: ^1.8.8 - version: 1.8.8 '@vitejs/plugin-react-swc': - specifier: ^3.11.0 - version: 3.11.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + specifier: ^4.0.1 + version: 4.0.1(vite@7.1.5(@types/node@24.3.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) concurrently: - specifier: ^9.2.0 - version: 9.2.0 + specifier: ^9.2.1 + version: 9.2.1 dotenv: - specifier: ^17.2.0 - version: 17.2.0 + specifier: ^17.2.2 + version: 17.2.2 esbuild: - specifier: ^0.25.8 - version: 0.25.8 + specifier: ^0.25.9 + version: 0.25.9 globals: - specifier: ^16.3.0 - version: 16.3.0 + specifier: ^16.4.0 + version: 16.4.0 postcss: specifier: ^8.5.6 version: 8.5.6 @@ -295,35 +289,31 @@ importers: specifier: ^4.41.0 version: 4.41.0 typescript: - specifier: ~5.8.3 - version: 5.8.3 + specifier: ~5.9.2 + version: 5.9.2 vite: - specifier: ^7.0.6 - version: 7.0.6(@types/node@24.1.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + specifier: ^7.1.5 + version: 7.1.5(@types/node@24.3.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) vite-plugin-package-version: specifier: ^1.1.0 - version: 1.1.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + version: 1.1.0(vite@7.1.5(@types/node@24.3.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) packages: - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.0': - resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==} + '@babel/compat-data@7.28.4': + resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.0': - resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + '@babel/core@7.28.4': + resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.0': - resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==} + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.27.2': @@ -338,8 +328,8 @@ packages: resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.27.3': - resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==} + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 @@ -356,80 +346,80 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.27.6': - resolution: {integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==} + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.0': - resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} + '@babel/parser@7.28.4': + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/runtime@7.27.6': - resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==} + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.0': - resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==} + '@babel/traverse@7.28.4': + resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.1': - resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.1.1': - resolution: {integrity: sha512-HFGYkxG714KzG+8tvtXCJ1t1qXQMzgWzfvQaUjxN6UeKv+KvMEuliInnbZLJm6DXFXwqVi6446EGI0sGBLIYng==} + '@biomejs/biome@2.2.2': + resolution: {integrity: sha512-j1omAiQWCkhuLgwpMKisNKnsM6W8Xtt1l0WZmqY/dFj8QPNkIoTvk4tSsi40FaAAkBE1PU0AFG2RWFBWenAn+w==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.1.1': - resolution: {integrity: sha512-2Muinu5ok4tWxq4nu5l19el48cwCY/vzvI7Vjbkf3CYIQkjxZLyj0Ad37Jv2OtlXYaLvv+Sfu1hFeXt/JwRRXQ==} + '@biomejs/cli-darwin-arm64@2.2.2': + resolution: {integrity: sha512-6ePfbCeCPryWu0CXlzsWNZgVz/kBEvHiPyNpmViSt6A2eoDf4kXs3YnwQPzGjy8oBgQulrHcLnJL0nkCh80mlQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.1.1': - resolution: {integrity: sha512-cC8HM5lrgKQXLAK+6Iz2FrYW5A62pAAX6KAnRlEyLb+Q3+Kr6ur/sSuoIacqlp1yvmjHJqjYfZjPvHWnqxoEIA==} + '@biomejs/cli-darwin-x64@2.2.2': + resolution: {integrity: sha512-Tn4JmVO+rXsbRslml7FvKaNrlgUeJot++FkvYIhl1OkslVCofAtS35MPlBMhXgKWF9RNr9cwHanrPTUUXcYGag==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.1.1': - resolution: {integrity: sha512-/7FBLnTswu4jgV9ttI3AMIdDGqVEPIZd8I5u2D4tfCoj8rl9dnjrEQbAIDlWhUXdyWlFSz8JypH3swU9h9P+2A==} + '@biomejs/cli-linux-arm64-musl@2.2.2': + resolution: {integrity: sha512-/MhYg+Bd6renn6i1ylGFL5snYUn/Ct7zoGVKhxnro3bwekiZYE8Kl39BSb0MeuqM+72sThkQv4TnNubU9njQRw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.1.1': - resolution: {integrity: sha512-tw4BEbhAUkWPe4WBr6IX04DJo+2jz5qpPzpW/SWvqMjb9QuHY8+J0M23V8EPY/zWU4IG8Ui0XESapR1CB49Q7g==} + '@biomejs/cli-linux-arm64@2.2.2': + resolution: {integrity: sha512-JfrK3gdmWWTh2J5tq/rcWCOsImVyzUnOS2fkjhiYKCQ+v8PqM+du5cfB7G1kXas+7KQeKSWALv18iQqdtIMvzw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.1.1': - resolution: {integrity: sha512-kUu+loNI3OCD2c12cUt7M5yaaSjDnGIksZwKnueubX6c/HWUyi/0mPbTBHR49Me3F0KKjWiKM+ZOjsmC+lUt9g==} + '@biomejs/cli-linux-x64-musl@2.2.2': + resolution: {integrity: sha512-ZCLXcZvjZKSiRY/cFANKg+z6Fhsf9MHOzj+NrDQcM+LbqYRT97LyCLWy2AS+W2vP+i89RyRM+kbGpUzbRTYWig==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.1.1': - resolution: {integrity: sha512-3WJ1GKjU7NzZb6RTbwLB59v9cTIlzjbiFLDB0z4376TkDqoNYilJaC37IomCr/aXwuU8QKkrYoHrgpSq5ffJ4Q==} + '@biomejs/cli-linux-x64@2.2.2': + resolution: {integrity: sha512-Ogb+77edO5LEP/xbNicACOWVLt8mgC+E1wmpUakr+O4nKwLt9vXe74YNuT3T1dUBxC/SnrVmlzZFC7kQJEfquQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.1.1': - resolution: {integrity: sha512-vEHK0v0oW+E6RUWLoxb2isI3rZo57OX9ZNyyGH701fZPj6Il0Rn1f5DMNyCmyflMwTnIQstEbs7n2BxYSqQx4Q==} + '@biomejs/cli-win32-arm64@2.2.2': + resolution: {integrity: sha512-wBe2wItayw1zvtXysmHJQoQqXlTzHSpQRyPpJKiNIR21HzH/CrZRDFic1C1jDdp+zAPtqhNExa0owKMbNwW9cQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.1.1': - resolution: {integrity: sha512-i2PKdn70kY++KEF/zkQFvQfX1e8SkA8hq4BgC+yE9dZqyLzB/XStY2MvwI3qswlRgnGpgncgqe0QYKVS1blksg==} + '@biomejs/cli-win32-x64@2.2.2': + resolution: {integrity: sha512-DAuHhHekGfiGb6lCcsT4UyxQmVwQiBCBUMwVra/dcOSs9q8OhfaZgey51MlekT3p8UwRqtXQfFuEJBhJNdLZwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -453,8 +443,8 @@ packages: '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - '@emotion/is-prop-valid@1.3.1': - resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} '@emotion/memoize@0.9.0': resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} @@ -498,176 +488,176 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.25.8': - resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==} + '@esbuild/aix-ppc64@0.25.9': + resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.8': - resolution: {integrity: sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==} + '@esbuild/android-arm64@0.25.9': + resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.8': - resolution: {integrity: sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==} + '@esbuild/android-arm@0.25.9': + resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.8': - resolution: {integrity: sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==} + '@esbuild/android-x64@0.25.9': + resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.8': - resolution: {integrity: sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==} + '@esbuild/darwin-arm64@0.25.9': + resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.8': - resolution: {integrity: sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==} + '@esbuild/darwin-x64@0.25.9': + resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.8': - resolution: {integrity: sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==} + '@esbuild/freebsd-arm64@0.25.9': + resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.8': - resolution: {integrity: sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==} + '@esbuild/freebsd-x64@0.25.9': + resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.8': - resolution: {integrity: sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==} + '@esbuild/linux-arm64@0.25.9': + resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.8': - resolution: {integrity: sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==} + '@esbuild/linux-arm@0.25.9': + resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.8': - resolution: {integrity: sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==} + '@esbuild/linux-ia32@0.25.9': + resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.8': - resolution: {integrity: sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==} + '@esbuild/linux-loong64@0.25.9': + resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.8': - resolution: {integrity: sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==} + '@esbuild/linux-mips64el@0.25.9': + resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.8': - resolution: {integrity: sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==} + '@esbuild/linux-ppc64@0.25.9': + resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.8': - resolution: {integrity: sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==} + '@esbuild/linux-riscv64@0.25.9': + resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.8': - resolution: {integrity: sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==} + '@esbuild/linux-s390x@0.25.9': + resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.8': - resolution: {integrity: sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==} + '@esbuild/linux-x64@0.25.9': + resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.8': - resolution: {integrity: sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==} + '@esbuild/netbsd-arm64@0.25.9': + resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.8': - resolution: {integrity: sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==} + '@esbuild/netbsd-x64@0.25.9': + resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.8': - resolution: {integrity: sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==} + '@esbuild/openbsd-arm64@0.25.9': + resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.8': - resolution: {integrity: sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==} + '@esbuild/openbsd-x64@0.25.9': + resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.8': - resolution: {integrity: sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==} + '@esbuild/openharmony-arm64@0.25.9': + resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.8': - resolution: {integrity: sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==} + '@esbuild/sunos-x64@0.25.9': + resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.8': - resolution: {integrity: sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==} + '@esbuild/win32-arm64@0.25.9': + resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.8': - resolution: {integrity: sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==} + '@esbuild/win32-ia32@0.25.9': + resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.8': - resolution: {integrity: sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==} + '@esbuild/win32-x64@0.25.9': + resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@floating-ui/core@1.7.2': - resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} - '@floating-ui/dom@1.7.2': - resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==} + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} - '@floating-ui/react-dom@2.1.4': - resolution: {integrity: sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==} + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.27.13': - resolution: {integrity: sha512-Qmj6t9TjgWAvbygNEu1hj4dbHI9CY0ziCMIJrmYoDIn9TUAH5lRmiIeZmRd4c6QEZkzdoH7jNnoNyoY1AIESiA==} + '@floating-ui/react@0.27.16': + resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==} peerDependencies: react: '>=17.0.0' react-dom: '>=17.0.0' @@ -677,6 +667,7 @@ packages: '@github/webauthn-json@2.1.1': resolution: {integrity: sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==} + deprecated: 'Deprecated: Modern browsers support built-in WebAuthn JSON methods. Please use native browser methods instead. For more information, visit https://github.com/github/webauthn-json' hasBin: true '@hookform/devtools@4.4.0': @@ -685,8 +676,8 @@ packages: react: ^16.8.0 || ^17 || ^18 || ^19 react-dom: ^16.8.0 || ^17 || ^18 || ^19 - '@hookform/resolvers@5.1.1': - resolution: {integrity: sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==} + '@hookform/resolvers@5.2.1': + resolution: {integrity: sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==} peerDependencies: react-hook-form: ^7.55.0 @@ -694,21 +685,27 @@ packages: resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} engines: {node: '>=6.9.0'} - '@jridgewell/gen-mapping@0.3.12': - resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.10': - resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/sourcemap-codec@1.5.4': - resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/trace-mapping@0.3.30': + resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} - '@jridgewell/trace-mapping@0.3.29': - resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} '@react-hook/latest@1.0.3': resolution: {integrity: sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==} @@ -731,8 +728,8 @@ packages: react: '>=16.8.0' rxjs: '>=7' - '@reduxjs/toolkit@2.8.2': - resolution: {integrity: sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==} + '@reduxjs/toolkit@2.9.0': + resolution: {integrity: sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==} peerDependencies: react: ^16.9.0 || ^17.0.0 || ^18 || ^19 react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 @@ -746,106 +743,111 @@ packages: resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} engines: {node: '>=14.0.0'} - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-beta.32': + resolution: {integrity: sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==} - '@rollup/rollup-android-arm-eabi@4.45.1': - resolution: {integrity: sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==} + '@rollup/rollup-android-arm-eabi@4.50.1': + resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.45.1': - resolution: {integrity: sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==} + '@rollup/rollup-android-arm64@4.50.1': + resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.45.1': - resolution: {integrity: sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==} + '@rollup/rollup-darwin-arm64@4.50.1': + resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.45.1': - resolution: {integrity: sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==} + '@rollup/rollup-darwin-x64@4.50.1': + resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.45.1': - resolution: {integrity: sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==} + '@rollup/rollup-freebsd-arm64@4.50.1': + resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.45.1': - resolution: {integrity: sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==} + '@rollup/rollup-freebsd-x64@4.50.1': + resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.45.1': - resolution: {integrity: sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==} + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.45.1': - resolution: {integrity: sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==} + '@rollup/rollup-linux-arm-musleabihf@4.50.1': + resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.45.1': - resolution: {integrity: sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==} + '@rollup/rollup-linux-arm64-gnu@4.50.1': + resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.45.1': - resolution: {integrity: sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==} + '@rollup/rollup-linux-arm64-musl@4.50.1': + resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.45.1': - resolution: {integrity: sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==} + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.45.1': - resolution: {integrity: sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==} + '@rollup/rollup-linux-ppc64-gnu@4.50.1': + resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.45.1': - resolution: {integrity: sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==} + '@rollup/rollup-linux-riscv64-gnu@4.50.1': + resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.45.1': - resolution: {integrity: sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==} + '@rollup/rollup-linux-riscv64-musl@4.50.1': + resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.45.1': - resolution: {integrity: sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==} + '@rollup/rollup-linux-s390x-gnu@4.50.1': + resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.45.1': - resolution: {integrity: sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==} + '@rollup/rollup-linux-x64-gnu@4.50.1': + resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.45.1': - resolution: {integrity: sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==} + '@rollup/rollup-linux-x64-musl@4.50.1': + resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.45.1': - resolution: {integrity: sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==} + '@rollup/rollup-openharmony-arm64@4.50.1': + resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.1': + resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.45.1': - resolution: {integrity: sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==} + '@rollup/rollup-win32-ia32-msvc@4.50.1': + resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.45.1': - resolution: {integrity: sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==} + '@rollup/rollup-win32-x64-msvc@4.50.1': + resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} cpu: [x64] os: [win32] @@ -884,68 +886,68 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@swc/core-darwin-arm64@1.13.2': - resolution: {integrity: sha512-44p7ivuLSGFJ15Vly4ivLJjg3ARo4879LtEBAabcHhSZygpmkP8eyjyWxrH3OxkY1eRZSIJe8yRZPFw4kPXFPw==} + '@swc/core-darwin-arm64@1.13.5': + resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.13.2': - resolution: {integrity: sha512-Lb9EZi7X2XDAVmuUlBm2UvVAgSCbD3qKqDCxSI4jEOddzVOpNCnyZ/xEampdngUIyDDhhJLYU9duC+Mcsv5Y+A==} + '@swc/core-darwin-x64@1.13.5': + resolution: {integrity: sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.13.2': - resolution: {integrity: sha512-9TDe/92ee1x57x+0OqL1huG4BeljVx0nWW4QOOxp8CCK67Rpc/HHl2wciJ0Kl9Dxf2NvpNtkPvqj9+BUmM9WVA==} + '@swc/core-linux-arm-gnueabihf@1.13.5': + resolution: {integrity: sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.13.2': - resolution: {integrity: sha512-KJUSl56DBk7AWMAIEcU83zl5mg3vlQYhLELhjwRFkGFMvghQvdqQ3zFOYa4TexKA7noBZa3C8fb24rI5sw9Exg==} + '@swc/core-linux-arm64-gnu@1.13.5': + resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-arm64-musl@1.13.2': - resolution: {integrity: sha512-teU27iG1oyWpNh9CzcGQ48ClDRt/RCem7mYO7ehd2FY102UeTws2+OzLESS1TS1tEZipq/5xwx3FzbVgiolCiQ==} + '@swc/core-linux-arm64-musl@1.13.5': + resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-x64-gnu@1.13.2': - resolution: {integrity: sha512-dRPsyPyqpLD0HMRCRpYALIh4kdOir8pPg4AhNQZLehKowigRd30RcLXGNVZcc31Ua8CiPI4QSgjOIxK+EQe4LQ==} + '@swc/core-linux-x64-gnu@1.13.5': + resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-linux-x64-musl@1.13.2': - resolution: {integrity: sha512-CCxETW+KkYEQDqz1SYC15YIWYheqFC+PJVOW76Maa/8yu8Biw+HTAcblKf2isrlUtK8RvrQN94v3UXkC2NzCEw==} + '@swc/core-linux-x64-musl@1.13.5': + resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-win32-arm64-msvc@1.13.2': - resolution: {integrity: sha512-Wv/QTA6PjyRLlmKcN6AmSI4jwSMRl0VTLGs57PHTqYRwwfwd7y4s2fIPJVBNbAlXd795dOEP6d/bGSQSyhOX3A==} + '@swc/core-win32-arm64-msvc@1.13.5': + resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.13.2': - resolution: {integrity: sha512-PuCdtNynEkUNbUXX/wsyUC+t4mamIU5y00lT5vJcAvco3/r16Iaxl5UCzhXYaWZSNVZMzPp9qN8NlSL8M5pPxw==} + '@swc/core-win32-ia32-msvc@1.13.5': + resolution: {integrity: sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.13.2': - resolution: {integrity: sha512-qlmMkFZJus8cYuBURx1a3YAG2G7IW44i+FEYV5/32ylKkzGNAr9tDJSA53XNnNXkAB5EXSPsOz7bn5C3JlEtdQ==} + '@swc/core-win32-x64-msvc@1.13.5': + resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.13.2': - resolution: {integrity: sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg==} + '@swc/core@1.13.5': + resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -956,23 +958,23 @@ packages: '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/types@0.1.23': - resolution: {integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==} + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@tanstack/query-core@5.83.0': - resolution: {integrity: sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==} + '@tanstack/query-core@5.87.1': + resolution: {integrity: sha512-HOFHVvhOCprrWvtccSzc7+RNqpnLlZ5R6lTmngb8aq7b4rc2/jDT0w+vLdQ4lD9bNtQ+/A4GsFXy030Gk4ollA==} - '@tanstack/query-devtools@5.81.2': - resolution: {integrity: sha512-jCeJcDCwKfoyyBXjXe9+Lo8aTkavygHHsUHAlxQKKaDeyT0qyQNLKl7+UyqYH2dDF6UN/14873IPBHchcsU+Zg==} + '@tanstack/query-devtools@5.87.3': + resolution: {integrity: sha512-LkzxzSr2HS1ALHTgDmJH5eGAVsSQiuwz//VhFW5OqNk0OQ+Fsqba0Tsf+NzWRtXYvpgUqwQr4b2zdFZwxHcGvg==} - '@tanstack/react-query-devtools@5.83.0': - resolution: {integrity: sha512-yfp8Uqd3I1jgx8gl0lxbSSESu5y4MO2ThOPBnGNTYs0P+ZFu+E9g5IdOngyUGuo6Uz6Qa7p9TLdZEX3ntik2fQ==} + '@tanstack/react-query-devtools@5.87.3': + resolution: {integrity: sha512-uV7m4/m58jU4OaLEyiPLRoXnL5H5E598lhFLSXIcK83on+ZXW7aIfiu5kwRwe1qFa4X4thH8wKaxz1lt6jNmAA==} peerDependencies: - '@tanstack/react-query': ^5.83.0 + '@tanstack/react-query': ^5.87.1 react: ^18 || ^19 - '@tanstack/react-query@5.83.0': - resolution: {integrity: sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==} + '@tanstack/react-query@5.87.1': + resolution: {integrity: sha512-YKauf8jfMowgAqcxj96AHs+Ux3m3bWT1oSVKamaRPXSnW2HqSznnTCEkAVqctF1e/W9R/mPcyzzINIgpOH94qg==} peerDependencies: react: ^18 || ^19 @@ -1051,8 +1053,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@24.1.0': - resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==} + '@types/node@24.3.1': + resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1060,16 +1062,13 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + '@types/react-dom@19.1.9': + resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} peerDependencies: - '@types/react': ^18.0.0 + '@types/react': ^19.0.0 '@types/react-router-dom@5.3.3': resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} @@ -1077,11 +1076,8 @@ packages: '@types/react-router@5.1.20': resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react-window@1.8.8': - resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} - - '@types/react@18.3.23': - resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} + '@types/react@19.1.12': + resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1103,8 +1099,9 @@ packages: peerDependencies: react: '>= 16.8.0' - '@vitejs/plugin-react-swc@3.11.0': - resolution: {integrity: sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==} + '@vitejs/plugin-react-swc@4.0.1': + resolution: {integrity: sha512-NQhPjysi5duItyrMd5JWZFf2vNOuSMyw+EoZyTBDzk+DkfYD8WNrsUs09sELV2cr1P15nufsN25hsUBt4CKF9Q==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4 || ^5 || ^6 || ^7 @@ -1153,8 +1150,8 @@ packages: peerDependencies: postcss: ^8.1.0 - axios@1.11.0: - resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + axios@1.12.0: + resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} @@ -1180,8 +1177,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.25.1: - resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==} + browserslist@4.25.4: + resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1217,8 +1214,8 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001727: - resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==} + caniuse-lite@1.0.30001741: + resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1297,8 +1294,8 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} - concurrently@9.2.0: - resolution: {integrity: sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==} + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} engines: {node: '>=18'} hasBin: true @@ -1442,8 +1439,8 @@ packages: dateformat@3.0.3: resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==} - dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} @@ -1518,8 +1515,8 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} - dotenv@17.2.0: - resolution: {integrity: sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==} + dotenv@17.2.2: + resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==} engines: {node: '>=12'} dotgitignore@2.1.0: @@ -1530,8 +1527,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.190: - resolution: {integrity: sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==} + electron-to-chromium@1.5.215: + resolution: {integrity: sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1563,11 +1560,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-toolkit@1.39.7: - resolution: {integrity: sha512-ek/wWryKouBrZIjkwW2BFf91CWOIMvoy2AE5YYgUrfWsJQM2Su1LoLtrw8uusEpN9RfqLlV/0FVNjT0WMv8Bxw==} + es-toolkit@1.39.10: + resolution: {integrity: sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==} - esbuild@0.25.8: - resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==} + esbuild@0.25.9: + resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} engines: {node: '>=18'} hasBin: true @@ -1599,8 +1596,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fdir@6.4.6: - resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -1637,8 +1635,8 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -1653,8 +1651,8 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - framer-motion@12.23.7: - resolution: {integrity: sha512-Qs+zNG9D/3c9C0riom1iXVVOOOaY3T32LIofgbQJz9APY/CUE5v6G41WkcZl2lVhaAgQDQcNq94f8qzLf3rTZA==} + framer-motion@12.23.12: + resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -1724,8 +1722,8 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - globals@16.3.0: - resolution: {integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==} + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} engines: {node: '>=18'} gopd@1.2.0: @@ -1829,8 +1827,8 @@ packages: humanize-duration@3.33.0: resolution: {integrity: sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==} - immer@10.1.1: - resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} + immer@10.1.3: + resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==} immutable@4.3.7: resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} @@ -1929,8 +1927,8 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true - js-base64@3.7.7: - resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2049,9 +2047,6 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - meow@8.1.2: resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} engines: {node: '>=10'} @@ -2157,12 +2152,26 @@ packages: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} - motion-dom@12.23.7: - resolution: {integrity: sha512-AyJR07/YxObtK3NyGLCfebUe0k9UZGhik+2eIPUoKz76cKRRSkMeifmIxfztIvOaKb/Smu9IfVHkmx+mV+iFmQ==} + motion-dom@12.23.12: + resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} motion-utils@12.23.6: resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion@12.23.12: + resolution: {integrity: sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2177,8 +2186,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - node-releases@2.0.19: - resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.20: + resolution: {integrity: sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==} normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -2366,19 +2375,19 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-datepicker@8.4.0: - resolution: {integrity: sha512-6nPDnj8vektWCIOy9ArS3avus9Ndsyz5XgFCJ7nBxXASSpBdSL6lG9jzNNmViPOAOPh6T5oJyGaXuMirBLECag==} + react-datepicker@8.7.0: + resolution: {integrity: sha512-r5OJbiLWc3YiVNy69Kau07/aVgVGsFVMA6+nlqCV7vyQ8q0FUOnJ+wAI4CgVxHejG3i5djAEiebrF8/Eip4rIw==} peerDependencies: react: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + react-dom@19.1.1: + resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} peerDependencies: - react: ^18.3.1 + react: ^19.1.1 - react-hook-form@7.61.0: - resolution: {integrity: sha512-o8S/HcCeuaAQVib36fPCgOLaaQN/v7Anj8zlYjcLMcz+4FnNfMsoDAEvVCefLb3KDnS43wq3pwcifehhkwowuQ==} + react-hook-form@7.62.0: + resolution: {integrity: sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -2401,8 +2410,8 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@19.1.0: - resolution: {integrity: sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==} + react-is@19.1.1: + resolution: {integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==} react-loading-skeleton@3.5.0: resolution: {integrity: sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==} @@ -2435,8 +2444,8 @@ packages: redux: optional: true - react-resize-detector@12.1.0: - resolution: {integrity: sha512-yiGIFSLymRz/ujDDXjaAssz9wampLn1FH89JtL9kq9Ql8NFHppqEtgRYUkJni1xwM0CtpSiYsBxEgctMnTHV0Q==} + react-resize-detector@12.3.0: + resolution: {integrity: sha512-mIDOVrTHKGnKe6qEUWi8dFdfHM5CPyTOpqoHctdMQf89Ljm/0qqDIzkP3vTRZZJi9/raaMiRxDEOqO4you5x+A==} peerDependencies: react: ^18.0.0 || ^19.0.0 @@ -2470,15 +2479,8 @@ packages: react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-window@1.8.11: - resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + react@19.1.1: + resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} read-pkg-up@3.0.0: @@ -2508,8 +2510,8 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - recharts@3.1.0: - resolution: {integrity: sha512-NqAqQcGBmLrfDs2mHX/bz8jJCQtG2FeXfE0GqpZmIuXIjkpIwj8sd9ad0WyvKiBKPd8ZgNG0hL85c8sFDwascw==} + recharts@3.2.0: + resolution: {integrity: sha512-fX0xCgNXo6mag9wz3oLuANR+dUQM4uIlTYBGTGq9CBRgW/8TZPzqPGYs5NTt8aENCf+i1CI8vqxT1py8L/5J2w==} engines: {node: '>=18'} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2562,8 +2564,8 @@ packages: engines: {node: '>= 0.4'} hasBin: true - rollup@4.45.1: - resolution: {integrity: sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==} + rollup@4.50.1: + resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -2581,9 +2583,6 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -2650,8 +2649,8 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - spdx-license-ids@3.0.21: - resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} + spdx-license-ids@3.0.22: + resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} split2@3.2.2: resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} @@ -2802,8 +2801,8 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} to-regex-range@5.0.1: @@ -2852,8 +2851,8 @@ packages: peerDependencies: typescript: '>=3.5.1' - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true @@ -2862,8 +2861,8 @@ packages: engines: {node: '>=0.8.0'} hasBin: true - undici-types@7.8.0: - resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + undici-types@7.10.0: + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2925,8 +2924,8 @@ packages: vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} - vfile-message@4.0.2: - resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} @@ -2939,8 +2938,8 @@ packages: peerDependencies: vite: '>=2.0.0-beta.69' - vite@7.0.6: - resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==} + vite@7.1.5: + resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3053,8 +3052,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zustand@5.0.6: - resolution: {integrity: sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==} + zustand@5.0.8: + resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -3076,31 +3075,26 @@ packages: snapshots: - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.0': {} + '@babel/compat-data@7.28.4': {} - '@babel/core@7.28.0': + '@babel/core@7.28.4': dependencies: - '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.0 + '@babel/generator': 7.28.3 '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) - '@babel/helpers': 7.27.6 - '@babel/parser': 7.28.0 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.4 '@babel/template': 7.27.2 - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.1 gensync: 1.0.0-beta.2 @@ -3109,19 +3103,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.0': + '@babel/generator@7.28.3': dependencies: - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.27.2': dependencies: - '@babel/compat-data': 7.28.0 + '@babel/compat-data': 7.28.4 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.1 + browserslist: 4.25.4 lru-cache: 5.1.1 semver: 6.3.1 @@ -3129,17 +3123,17 @@ snapshots: '@babel/helper-module-imports@7.27.1': dependencies: - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)': + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.28.4 '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.28.0 + '@babel/traverse': 7.28.4 transitivePeerDependencies: - supports-color @@ -3149,73 +3143,73 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.27.6': + '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.1 + '@babel/types': 7.28.4 - '@babel/parser@7.28.0': + '@babel/parser@7.28.4': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.4 - '@babel/runtime@7.27.6': {} + '@babel/runtime@7.28.4': {} '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 - '@babel/traverse@7.28.0': + '@babel/traverse@7.28.4': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.0 + '@babel/generator': 7.28.3 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.0 + '@babel/parser': 7.28.4 '@babel/template': 7.27.2 - '@babel/types': 7.28.1 + '@babel/types': 7.28.4 debug: 4.4.1 transitivePeerDependencies: - supports-color - '@babel/types@7.28.1': + '@babel/types@7.28.4': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@biomejs/biome@2.1.1': + '@biomejs/biome@2.2.2': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.1.1 - '@biomejs/cli-darwin-x64': 2.1.1 - '@biomejs/cli-linux-arm64': 2.1.1 - '@biomejs/cli-linux-arm64-musl': 2.1.1 - '@biomejs/cli-linux-x64': 2.1.1 - '@biomejs/cli-linux-x64-musl': 2.1.1 - '@biomejs/cli-win32-arm64': 2.1.1 - '@biomejs/cli-win32-x64': 2.1.1 - - '@biomejs/cli-darwin-arm64@2.1.1': + '@biomejs/cli-darwin-arm64': 2.2.2 + '@biomejs/cli-darwin-x64': 2.2.2 + '@biomejs/cli-linux-arm64': 2.2.2 + '@biomejs/cli-linux-arm64-musl': 2.2.2 + '@biomejs/cli-linux-x64': 2.2.2 + '@biomejs/cli-linux-x64-musl': 2.2.2 + '@biomejs/cli-win32-arm64': 2.2.2 + '@biomejs/cli-win32-x64': 2.2.2 + + '@biomejs/cli-darwin-arm64@2.2.2': optional: true - '@biomejs/cli-darwin-x64@2.1.1': + '@biomejs/cli-darwin-x64@2.2.2': optional: true - '@biomejs/cli-linux-arm64-musl@2.1.1': + '@biomejs/cli-linux-arm64-musl@2.2.2': optional: true - '@biomejs/cli-linux-arm64@2.1.1': + '@biomejs/cli-linux-arm64@2.2.2': optional: true - '@biomejs/cli-linux-x64-musl@2.1.1': + '@biomejs/cli-linux-x64-musl@2.2.2': optional: true - '@biomejs/cli-linux-x64@2.1.1': + '@biomejs/cli-linux-x64@2.2.2': optional: true - '@biomejs/cli-win32-arm64@2.1.1': + '@biomejs/cli-win32-arm64@2.2.2': optional: true - '@biomejs/cli-win32-x64@2.1.1': + '@biomejs/cli-win32-x64@2.2.2': optional: true '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': @@ -3227,7 +3221,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.27.1 - '@babel/runtime': 7.27.6 + '@babel/runtime': 7.28.4 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -3250,25 +3244,25 @@ snapshots: '@emotion/hash@0.9.2': {} - '@emotion/is-prop-valid@1.3.1': + '@emotion/is-prop-valid@1.4.0': dependencies: '@emotion/memoize': 0.9.0 '@emotion/memoize@0.9.0': {} - '@emotion/react@11.14.0(@types/react@18.3.23)(react@18.3.1)': + '@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.1)': dependencies: - '@babel/runtime': 7.27.6 + '@babel/runtime': 7.28.4 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.1.1) '@emotion/utils': 1.4.2 '@emotion/weak-memoize': 0.4.0 hoist-non-react-statics: 3.3.2 - react: 18.3.1 + react: 19.1.1 optionalDependencies: - '@types/react': 18.3.23 + '@types/react': 19.1.12 transitivePeerDependencies: - supports-color @@ -3282,274 +3276,288 @@ snapshots: '@emotion/sheet@1.4.0': {} - '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.23)(react@18.3.1))(@types/react@18.3.23)(react@18.3.1)': + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.1))(@types/react@19.1.12)(react@19.1.1)': dependencies: - '@babel/runtime': 7.27.6 + '@babel/runtime': 7.28.4 '@emotion/babel-plugin': 11.13.5 - '@emotion/is-prop-valid': 1.3.1 - '@emotion/react': 11.14.0(@types/react@18.3.23)(react@18.3.1) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@19.1.12)(react@19.1.1) '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.1.1) '@emotion/utils': 1.4.2 - react: 18.3.1 + react: 19.1.1 optionalDependencies: - '@types/react': 18.3.23 + '@types/react': 19.1.12 transitivePeerDependencies: - supports-color '@emotion/unitless@0.10.0': {} - '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.3.1)': + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.1.1)': dependencies: - react: 18.3.1 + react: 19.1.1 '@emotion/utils@1.4.2': {} '@emotion/weak-memoize@0.4.0': {} - '@esbuild/aix-ppc64@0.25.8': + '@esbuild/aix-ppc64@0.25.9': optional: true - '@esbuild/android-arm64@0.25.8': + '@esbuild/android-arm64@0.25.9': optional: true - '@esbuild/android-arm@0.25.8': + '@esbuild/android-arm@0.25.9': optional: true - '@esbuild/android-x64@0.25.8': + '@esbuild/android-x64@0.25.9': optional: true - '@esbuild/darwin-arm64@0.25.8': + '@esbuild/darwin-arm64@0.25.9': optional: true - '@esbuild/darwin-x64@0.25.8': + '@esbuild/darwin-x64@0.25.9': optional: true - '@esbuild/freebsd-arm64@0.25.8': + '@esbuild/freebsd-arm64@0.25.9': optional: true - '@esbuild/freebsd-x64@0.25.8': + '@esbuild/freebsd-x64@0.25.9': optional: true - '@esbuild/linux-arm64@0.25.8': + '@esbuild/linux-arm64@0.25.9': optional: true - '@esbuild/linux-arm@0.25.8': + '@esbuild/linux-arm@0.25.9': optional: true - '@esbuild/linux-ia32@0.25.8': + '@esbuild/linux-ia32@0.25.9': optional: true - '@esbuild/linux-loong64@0.25.8': + '@esbuild/linux-loong64@0.25.9': optional: true - '@esbuild/linux-mips64el@0.25.8': + '@esbuild/linux-mips64el@0.25.9': optional: true - '@esbuild/linux-ppc64@0.25.8': + '@esbuild/linux-ppc64@0.25.9': optional: true - '@esbuild/linux-riscv64@0.25.8': + '@esbuild/linux-riscv64@0.25.9': optional: true - '@esbuild/linux-s390x@0.25.8': + '@esbuild/linux-s390x@0.25.9': optional: true - '@esbuild/linux-x64@0.25.8': + '@esbuild/linux-x64@0.25.9': optional: true - '@esbuild/netbsd-arm64@0.25.8': + '@esbuild/netbsd-arm64@0.25.9': optional: true - '@esbuild/netbsd-x64@0.25.8': + '@esbuild/netbsd-x64@0.25.9': optional: true - '@esbuild/openbsd-arm64@0.25.8': + '@esbuild/openbsd-arm64@0.25.9': optional: true - '@esbuild/openbsd-x64@0.25.8': + '@esbuild/openbsd-x64@0.25.9': optional: true - '@esbuild/openharmony-arm64@0.25.8': + '@esbuild/openharmony-arm64@0.25.9': optional: true - '@esbuild/sunos-x64@0.25.8': + '@esbuild/sunos-x64@0.25.9': optional: true - '@esbuild/win32-arm64@0.25.8': + '@esbuild/win32-arm64@0.25.9': optional: true - '@esbuild/win32-ia32@0.25.8': + '@esbuild/win32-ia32@0.25.9': optional: true - '@esbuild/win32-x64@0.25.8': + '@esbuild/win32-x64@0.25.9': optional: true - '@floating-ui/core@1.7.2': + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.7.2': + '@floating-ui/dom@1.7.4': dependencies: - '@floating-ui/core': 1.7.2 + '@floating-ui/core': 1.7.3 '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react-dom@2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: - '@floating-ui/dom': 1.7.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@floating-ui/dom': 1.7.4 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) - '@floating-ui/react@0.27.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react@0.27.16(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: - '@floating-ui/react-dom': 2.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@floating-ui/utils': 0.2.10 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) tabbable: 6.2.0 '@floating-ui/utils@0.2.10': {} '@github/webauthn-json@2.1.1': {} - '@hookform/devtools@4.4.0(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@hookform/devtools@4.4.0(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.23)(react@18.3.1) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.23)(react@18.3.1))(@types/react@18.3.23)(react@18.3.1) + '@emotion/react': 11.14.0(@types/react@19.1.12)(react@19.1.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.12)(react@19.1.1))(@types/react@19.1.12)(react@19.1.1) '@types/lodash': 4.17.20 - little-state-machine: 4.8.1(react@18.3.1) + little-state-machine: 4.8.1(react@19.1.1) lodash: 4.17.21 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-simple-animate: 3.5.3(react-dom@18.3.1(react@18.3.1)) - use-deep-compare-effect: 1.8.1(react@18.3.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-simple-animate: 3.5.3(react-dom@19.1.1(react@19.1.1)) + use-deep-compare-effect: 1.8.1(react@19.1.1) uuid: 8.3.2 transitivePeerDependencies: - '@types/react' - supports-color - '@hookform/resolvers@5.1.1(react-hook-form@7.61.0(react@18.3.1))': + '@hookform/resolvers@5.2.1(react-hook-form@7.62.0(react@19.1.1))': dependencies: '@standard-schema/utils': 0.3.0 - react-hook-form: 7.61.0(react@18.3.1) + react-hook-form: 7.62.0(react@19.1.1) '@hutson/parse-repository-url@3.0.2': {} - '@jridgewell/gen-mapping@0.3.12': + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.30 + + '@jridgewell/remapping@2.3.5': dependencies: - '@jridgewell/sourcemap-codec': 1.5.4 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/source-map@0.3.10': + '@jridgewell/source-map@0.3.11': dependencies: - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 optional: true - '@jridgewell/sourcemap-codec@1.5.4': {} + '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.29': + '@jridgewell/trace-mapping@0.3.30': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + optional: true - '@react-hook/latest@1.0.3(react@18.3.1)': + '@react-hook/latest@1.0.3(react@19.1.1)': dependencies: - react: 18.3.1 + react: 19.1.1 - '@react-hook/passive-layout-effect@1.2.1(react@18.3.1)': + '@react-hook/passive-layout-effect@1.2.1(react@19.1.1)': dependencies: - react: 18.3.1 + react: 19.1.1 - '@react-hook/resize-observer@2.0.2(react@18.3.1)': + '@react-hook/resize-observer@2.0.2(react@19.1.1)': dependencies: - '@react-hook/latest': 1.0.3(react@18.3.1) - '@react-hook/passive-layout-effect': 1.2.1(react@18.3.1) - react: 18.3.1 + '@react-hook/latest': 1.0.3(react@19.1.1) + '@react-hook/passive-layout-effect': 1.2.1(react@19.1.1) + react: 19.1.1 - '@react-rxjs/core@0.10.8(react@18.3.1)(rxjs@7.8.2)': + '@react-rxjs/core@0.10.8(react@19.1.1)(rxjs@7.8.2)': dependencies: '@rx-state/core': 0.1.4(rxjs@7.8.2) - react: 18.3.1 + react: 19.1.1 rxjs: 7.8.2 - use-sync-external-store: 1.5.0(react@18.3.1) + use-sync-external-store: 1.5.0(react@19.1.1) - '@reduxjs/toolkit@2.8.2(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': + '@reduxjs/toolkit@2.9.0(react-redux@9.2.0(@types/react@19.1.12)(react@19.1.1)(redux@5.0.1))(react@19.1.1)': dependencies: '@standard-schema/spec': 1.0.0 '@standard-schema/utils': 0.3.0 - immer: 10.1.1 + immer: 10.1.3 redux: 5.0.1 redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.1.1 optionalDependencies: - react: 18.3.1 - react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1) + react: 19.1.1 + react-redux: 9.2.0(@types/react@19.1.12)(react@19.1.1)(redux@5.0.1) '@remix-run/router@1.23.0': {} - '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-beta.32': {} - '@rollup/rollup-android-arm-eabi@4.45.1': + '@rollup/rollup-android-arm-eabi@4.50.1': optional: true - '@rollup/rollup-android-arm64@4.45.1': + '@rollup/rollup-android-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-arm64@4.45.1': + '@rollup/rollup-darwin-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-x64@4.45.1': + '@rollup/rollup-darwin-x64@4.50.1': optional: true - '@rollup/rollup-freebsd-arm64@4.45.1': + '@rollup/rollup-freebsd-arm64@4.50.1': optional: true - '@rollup/rollup-freebsd-x64@4.45.1': + '@rollup/rollup-freebsd-x64@4.50.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.45.1': + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.45.1': + '@rollup/rollup-linux-arm-musleabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.45.1': + '@rollup/rollup-linux-arm64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.45.1': + '@rollup/rollup-linux-arm64-musl@4.50.1': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.45.1': + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.45.1': + '@rollup/rollup-linux-ppc64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.45.1': + '@rollup/rollup-linux-riscv64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.45.1': + '@rollup/rollup-linux-riscv64-musl@4.50.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.45.1': + '@rollup/rollup-linux-s390x-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.45.1': + '@rollup/rollup-linux-x64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-musl@4.45.1': + '@rollup/rollup-linux-x64-musl@4.50.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.45.1': + '@rollup/rollup-openharmony-arm64@4.50.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.45.1': + '@rollup/rollup-win32-arm64-msvc@4.50.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.45.1': + '@rollup/rollup-win32-ia32-msvc@4.50.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.50.1': optional: true '@rx-state/core@0.1.4(rxjs@7.8.2)': @@ -3587,78 +3595,78 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@swc/core-darwin-arm64@1.13.2': + '@swc/core-darwin-arm64@1.13.5': optional: true - '@swc/core-darwin-x64@1.13.2': + '@swc/core-darwin-x64@1.13.5': optional: true - '@swc/core-linux-arm-gnueabihf@1.13.2': + '@swc/core-linux-arm-gnueabihf@1.13.5': optional: true - '@swc/core-linux-arm64-gnu@1.13.2': + '@swc/core-linux-arm64-gnu@1.13.5': optional: true - '@swc/core-linux-arm64-musl@1.13.2': + '@swc/core-linux-arm64-musl@1.13.5': optional: true - '@swc/core-linux-x64-gnu@1.13.2': + '@swc/core-linux-x64-gnu@1.13.5': optional: true - '@swc/core-linux-x64-musl@1.13.2': + '@swc/core-linux-x64-musl@1.13.5': optional: true - '@swc/core-win32-arm64-msvc@1.13.2': + '@swc/core-win32-arm64-msvc@1.13.5': optional: true - '@swc/core-win32-ia32-msvc@1.13.2': + '@swc/core-win32-ia32-msvc@1.13.5': optional: true - '@swc/core-win32-x64-msvc@1.13.2': + '@swc/core-win32-x64-msvc@1.13.5': optional: true - '@swc/core@1.13.2': + '@swc/core@1.13.5': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.23 + '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.13.2 - '@swc/core-darwin-x64': 1.13.2 - '@swc/core-linux-arm-gnueabihf': 1.13.2 - '@swc/core-linux-arm64-gnu': 1.13.2 - '@swc/core-linux-arm64-musl': 1.13.2 - '@swc/core-linux-x64-gnu': 1.13.2 - '@swc/core-linux-x64-musl': 1.13.2 - '@swc/core-win32-arm64-msvc': 1.13.2 - '@swc/core-win32-ia32-msvc': 1.13.2 - '@swc/core-win32-x64-msvc': 1.13.2 + '@swc/core-darwin-arm64': 1.13.5 + '@swc/core-darwin-x64': 1.13.5 + '@swc/core-linux-arm-gnueabihf': 1.13.5 + '@swc/core-linux-arm64-gnu': 1.13.5 + '@swc/core-linux-arm64-musl': 1.13.5 + '@swc/core-linux-x64-gnu': 1.13.5 + '@swc/core-linux-x64-musl': 1.13.5 + '@swc/core-win32-arm64-msvc': 1.13.5 + '@swc/core-win32-ia32-msvc': 1.13.5 + '@swc/core-win32-x64-msvc': 1.13.5 '@swc/counter@0.1.3': {} - '@swc/types@0.1.23': + '@swc/types@0.1.25': dependencies: '@swc/counter': 0.1.3 - '@tanstack/query-core@5.83.0': {} + '@tanstack/query-core@5.87.1': {} - '@tanstack/query-devtools@5.81.2': {} + '@tanstack/query-devtools@5.87.3': {} - '@tanstack/react-query-devtools@5.83.0(@tanstack/react-query@5.83.0(react@18.3.1))(react@18.3.1)': + '@tanstack/react-query-devtools@5.87.3(@tanstack/react-query@5.87.1(react@19.1.1))(react@19.1.1)': dependencies: - '@tanstack/query-devtools': 5.81.2 - '@tanstack/react-query': 5.83.0(react@18.3.1) - react: 18.3.1 + '@tanstack/query-devtools': 5.87.3 + '@tanstack/react-query': 5.87.1(react@19.1.1) + react: 19.1.1 - '@tanstack/react-query@5.83.0(react@18.3.1)': + '@tanstack/react-query@5.87.1(react@19.1.1)': dependencies: - '@tanstack/query-core': 5.83.0 - react: 18.3.1 + '@tanstack/query-core': 5.87.1 + react: 19.1.1 - '@tanstack/react-virtual@3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-virtual@3.13.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@tanstack/virtual-core': 3.13.12 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) '@tanstack/virtual-core@3.13.12': {} @@ -3722,40 +3730,33 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@24.1.0': + '@types/node@24.3.1': dependencies: - undici-types: 7.8.0 + undici-types: 7.10.0 '@types/normalize-package-data@2.4.4': {} '@types/parse-json@4.0.2': {} - '@types/prop-types@15.7.15': {} - '@types/qs@6.14.0': {} - '@types/react-dom@18.3.7(@types/react@18.3.23)': + '@types/react-dom@19.1.9(@types/react@19.1.12)': dependencies: - '@types/react': 18.3.23 + '@types/react': 19.1.12 '@types/react-router-dom@5.3.3': dependencies: '@types/history': 4.7.11 - '@types/react': 18.3.23 + '@types/react': 19.1.12 '@types/react-router': 5.1.20 '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 18.3.23 + '@types/react': 19.1.12 - '@types/react-window@1.8.8': + '@types/react@19.1.12': dependencies: - '@types/react': 18.3.23 - - '@types/react@18.3.23': - dependencies: - '@types/prop-types': 15.7.15 csstype: 3.1.3 '@types/unist@2.0.11': {} @@ -3768,16 +3769,16 @@ snapshots: '@use-gesture/core@10.3.1': {} - '@use-gesture/react@10.3.1(react@18.3.1)': + '@use-gesture/react@10.3.1(react@19.1.1)': dependencies: '@use-gesture/core': 10.3.1 - react: 18.3.1 + react: 19.1.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1))': + '@vitejs/plugin-react-swc@4.0.1(vite@7.1.5(@types/node@24.3.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1))': dependencies: - '@rolldown/pluginutils': 1.0.0-beta.27 - '@swc/core': 1.13.2 - vite: 7.0.6(@types/node@24.1.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + '@rolldown/pluginutils': 1.0.0-beta.32 + '@swc/core': 1.13.5 + vite: 7.1.5(@types/node@24.3.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) transitivePeerDependencies: - '@swc/helpers' @@ -3814,17 +3815,17 @@ snapshots: autoprefixer@10.4.21(postcss@8.5.6): dependencies: - browserslist: 4.25.1 - caniuse-lite: 1.0.30001727 + browserslist: 4.25.4 + caniuse-lite: 1.0.30001741 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 postcss: 8.5.6 postcss-value-parser: 4.2.0 - axios@1.11.0: + axios@1.12.0: dependencies: - follow-redirects: 1.15.9 + follow-redirects: 1.15.11 form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -3832,7 +3833,7 @@ snapshots: babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.27.6 + '@babel/runtime': 7.28.4 cosmiconfig: 7.1.0 resolve: 1.22.10 @@ -3853,12 +3854,12 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.25.1: + browserslist@4.25.4: dependencies: - caniuse-lite: 1.0.30001727 - electron-to-chromium: 1.5.190 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.25.1) + caniuse-lite: 1.0.30001741 + electron-to-chromium: 1.5.215 + node-releases: 2.0.20 + update-browserslist-db: 1.1.3(browserslist@4.25.4) buffer-from@1.1.2: {} @@ -3884,7 +3885,7 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001727: {} + caniuse-lite@1.0.30001741: {} ccount@2.0.1: {} @@ -3976,10 +3977,9 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 - concurrently@9.2.0: + concurrently@9.2.1: dependencies: chalk: 4.1.2 - lodash: 4.17.21 rxjs: 7.8.2 shell-quote: 1.8.3 supports-color: 8.1.1 @@ -4158,7 +4158,7 @@ snapshots: dateformat@3.0.3: {} - dayjs@1.11.13: {} + dayjs@1.11.18: {} debug@4.4.1: dependencies: @@ -4221,7 +4221,7 @@ snapshots: dependencies: is-obj: 2.0.0 - dotenv@17.2.0: {} + dotenv@17.2.2: {} dotgitignore@2.1.0: dependencies: @@ -4234,7 +4234,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.190: {} + electron-to-chromium@1.5.215: {} emoji-regex@8.0.0: {} @@ -4261,36 +4261,36 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-toolkit@1.39.7: {} + es-toolkit@1.39.10: {} - esbuild@0.25.8: + esbuild@0.25.9: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.8 - '@esbuild/android-arm': 0.25.8 - '@esbuild/android-arm64': 0.25.8 - '@esbuild/android-x64': 0.25.8 - '@esbuild/darwin-arm64': 0.25.8 - '@esbuild/darwin-x64': 0.25.8 - '@esbuild/freebsd-arm64': 0.25.8 - '@esbuild/freebsd-x64': 0.25.8 - '@esbuild/linux-arm': 0.25.8 - '@esbuild/linux-arm64': 0.25.8 - '@esbuild/linux-ia32': 0.25.8 - '@esbuild/linux-loong64': 0.25.8 - '@esbuild/linux-mips64el': 0.25.8 - '@esbuild/linux-ppc64': 0.25.8 - '@esbuild/linux-riscv64': 0.25.8 - '@esbuild/linux-s390x': 0.25.8 - '@esbuild/linux-x64': 0.25.8 - '@esbuild/netbsd-arm64': 0.25.8 - '@esbuild/netbsd-x64': 0.25.8 - '@esbuild/openbsd-arm64': 0.25.8 - '@esbuild/openbsd-x64': 0.25.8 - '@esbuild/openharmony-arm64': 0.25.8 - '@esbuild/sunos-x64': 0.25.8 - '@esbuild/win32-arm64': 0.25.8 - '@esbuild/win32-ia32': 0.25.8 - '@esbuild/win32-x64': 0.25.8 + '@esbuild/aix-ppc64': 0.25.9 + '@esbuild/android-arm': 0.25.9 + '@esbuild/android-arm64': 0.25.9 + '@esbuild/android-x64': 0.25.9 + '@esbuild/darwin-arm64': 0.25.9 + '@esbuild/darwin-x64': 0.25.9 + '@esbuild/freebsd-arm64': 0.25.9 + '@esbuild/freebsd-x64': 0.25.9 + '@esbuild/linux-arm': 0.25.9 + '@esbuild/linux-arm64': 0.25.9 + '@esbuild/linux-ia32': 0.25.9 + '@esbuild/linux-loong64': 0.25.9 + '@esbuild/linux-mips64el': 0.25.9 + '@esbuild/linux-ppc64': 0.25.9 + '@esbuild/linux-riscv64': 0.25.9 + '@esbuild/linux-s390x': 0.25.9 + '@esbuild/linux-x64': 0.25.9 + '@esbuild/netbsd-arm64': 0.25.9 + '@esbuild/netbsd-x64': 0.25.9 + '@esbuild/openbsd-arm64': 0.25.9 + '@esbuild/openbsd-x64': 0.25.9 + '@esbuild/openharmony-arm64': 0.25.9 + '@esbuild/sunos-x64': 0.25.9 + '@esbuild/win32-arm64': 0.25.9 + '@esbuild/win32-ia32': 0.25.9 + '@esbuild/win32-x64': 0.25.9 escalade@3.2.0: {} @@ -4308,7 +4308,7 @@ snapshots: fast-deep-equal@3.1.3: {} - fdir@6.4.6(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -4342,7 +4342,7 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - follow-redirects@1.15.9: {} + follow-redirects@1.15.11: {} form-data@4.0.4: dependencies: @@ -4354,15 +4354,15 @@ snapshots: fraction.js@4.3.7: {} - framer-motion@12.23.7(@emotion/is-prop-valid@1.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + framer-motion@12.23.12(@emotion/is-prop-valid@1.4.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: - motion-dom: 12.23.7 + motion-dom: 12.23.12 motion-utils: 12.23.6 tslib: 2.8.1 optionalDependencies: - '@emotion/is-prop-valid': 1.3.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@emotion/is-prop-valid': 1.4.0 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) fsevents@2.3.3: optional: true @@ -4428,7 +4428,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@16.3.0: {} + globals@16.4.0: {} gopd@1.2.0: {} @@ -4516,7 +4516,7 @@ snapshots: space-separated-tokens: 2.0.2 style-to-js: 1.1.17 unist-util-position: 5.0.0 - vfile-message: 4.0.2 + vfile-message: 4.0.3 transitivePeerDependencies: - supports-color @@ -4559,15 +4559,15 @@ snapshots: domhandler: 5.0.3 htmlparser2: 10.0.0 - html-react-parser@5.2.6(@types/react@18.3.23)(react@18.3.1): + html-react-parser@5.2.6(@types/react@19.1.12)(react@19.1.1): dependencies: domhandler: 5.0.3 html-dom-parser: 5.1.1 - react: 18.3.1 + react: 19.1.1 react-property: 2.0.2 style-to-js: 1.1.17 optionalDependencies: - '@types/react': 18.3.23 + '@types/react': 19.1.12 html-url-attributes@3.0.1: {} @@ -4582,7 +4582,7 @@ snapshots: humanize-duration@3.33.0: {} - immer@10.1.1: {} + immer@10.1.3: {} immutable@4.3.7: {} @@ -4653,7 +4653,7 @@ snapshots: jiti@2.4.2: optional: true - js-base64@3.7.7: {} + js-base64@3.7.8: {} js-tokens@4.0.0: {} @@ -4673,9 +4673,9 @@ snapshots: lines-and-columns@1.2.4: {} - little-state-machine@4.8.1(react@18.3.1): + little-state-machine@4.8.1(react@19.1.1): dependencies: - react: 18.3.1 + react: 19.1.1 load-json-file@4.0.0: dependencies: @@ -4769,7 +4769,7 @@ snapshots: parse-entities: 4.0.2 stringify-entities: 4.0.4 unist-util-stringify-position: 4.0.0 - vfile-message: 4.0.2 + vfile-message: 4.0.3 transitivePeerDependencies: - supports-color @@ -4817,8 +4817,6 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - memoize-one@5.2.1: {} - meow@8.1.2: dependencies: '@types/minimist': 1.2.5 @@ -4833,9 +4831,9 @@ snapshots: type-fest: 0.18.1 yargs-parser: 20.2.9 - merge-refs@2.0.0(@types/react@18.3.23): + merge-refs@2.0.0(@types/react@19.1.12): optionalDependencies: - '@types/react': 18.3.23 + '@types/react': 19.1.12 micromark-core-commonmark@2.0.3: dependencies: @@ -4996,12 +4994,21 @@ snapshots: modify-values@1.0.1: {} - motion-dom@12.23.7: + motion-dom@12.23.12: dependencies: motion-utils: 12.23.6 motion-utils@12.23.6: {} + motion@12.23.12(@emotion/is-prop-valid@1.4.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + framer-motion: 12.23.12(@emotion/is-prop-valid@1.4.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + ms@2.1.3: {} n-gram@2.0.2: {} @@ -5010,7 +5017,7 @@ snapshots: neo-async@2.6.2: {} - node-releases@2.0.19: {} + node-releases@2.0.20: {} normalize-package-data@2.5.0: dependencies: @@ -5168,58 +5175,57 @@ snapshots: radash@12.1.1: {} - react-click-away-listener@2.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-click-away-listener@2.4.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) - react-datepicker@8.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-datepicker@8.7.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: - '@floating-ui/react': 0.27.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react': 0.27.16(react-dom@19.1.1(react@19.1.1))(react@19.1.1) clsx: 2.1.1 date-fns: 4.1.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) - react-dom@18.3.1(react@18.3.1): + react-dom@19.1.1(react@19.1.1): dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 + react: 19.1.1 + scheduler: 0.26.0 - react-hook-form@7.61.0(react@18.3.1): + react-hook-form@7.62.0(react@19.1.1): dependencies: - react: 18.3.1 + react: 19.1.1 - react-idle-timer@5.7.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-idle-timer@5.7.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) - react-intersection-observer@9.16.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-intersection-observer@9.16.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: - react: 18.3.1 + react: 19.1.1 optionalDependencies: - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.1.1(react@19.1.1) react-is@16.13.1: {} - react-is@19.1.0: {} + react-is@19.1.1: {} - react-loading-skeleton@3.5.0(react@18.3.1): + react-loading-skeleton@3.5.0(react@19.1.1): dependencies: - react: 18.3.1 + react: 19.1.1 - react-markdown@10.1.0(@types/react@18.3.23)(react@18.3.1): + react-markdown@10.1.0(@types/react@19.1.12)(react@19.1.1): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 18.3.23 + '@types/react': 19.1.12 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.0 - react: 18.3.1 + react: 19.1.1 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -5230,64 +5236,55 @@ snapshots: react-property@2.0.2: {} - react-qr-code@2.0.18(react@18.3.1): + react-qr-code@2.0.18(react@19.1.1): dependencies: prop-types: 15.8.1 qr.js: 0.0.0 - react: 18.3.1 + react: 19.1.1 - react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1): + react-redux@9.2.0(@types/react@19.1.12)(react@19.1.1)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 - react: 18.3.1 - use-sync-external-store: 1.5.0(react@18.3.1) + react: 19.1.1 + use-sync-external-store: 1.5.0(react@19.1.1) optionalDependencies: - '@types/react': 18.3.23 + '@types/react': 19.1.12 redux: 5.0.1 - react-resize-detector@12.1.0(react@18.3.1): + react-resize-detector@12.3.0(react@19.1.1): dependencies: - lodash: 4.17.21 - react: 18.3.1 + es-toolkit: 1.39.10 + react: 19.1.1 - react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router-dom@6.30.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@remix-run/router': 1.23.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.1(react@18.3.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-router: 6.30.1(react@19.1.1) - react-router@6.30.1(react@18.3.1): + react-router@6.30.1(react@19.1.1): dependencies: '@remix-run/router': 1.23.0 - react: 18.3.1 + react: 19.1.1 - react-simple-animate@3.5.3(react-dom@18.3.1(react@18.3.1)): + react-simple-animate@3.5.3(react-dom@19.1.1(react@19.1.1)): dependencies: - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.1.1(react@19.1.1) - react-tracked@2.0.1(react@18.3.1)(scheduler@0.26.0): + react-tracked@2.0.1(react@19.1.1)(scheduler@0.26.0): dependencies: proxy-compare: 3.0.1 - react: 18.3.1 + react: 19.1.1 scheduler: 0.26.0 - use-context-selector: 2.0.0(react@18.3.1)(scheduler@0.26.0) + use-context-selector: 2.0.0(react@19.1.1)(scheduler@0.26.0) - react-virtualized-auto-sizer@1.0.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-virtualized-auto-sizer@1.0.26(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) - react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.27.6 - memoize-one: 5.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react@18.3.1: - dependencies: - loose-envify: 1.4.0 + react@19.1.1: {} read-pkg-up@3.0.0: dependencies: @@ -5333,21 +5330,21 @@ snapshots: dependencies: picomatch: 2.3.1 - recharts@3.1.0(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@19.1.0)(react@18.3.1)(redux@5.0.1): + recharts@3.2.0(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react-is@19.1.1)(react@19.1.1)(redux@5.0.1): dependencies: - '@reduxjs/toolkit': 2.8.2(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1) + '@reduxjs/toolkit': 2.9.0(react-redux@9.2.0(@types/react@19.1.12)(react@19.1.1)(redux@5.0.1))(react@19.1.1) clsx: 2.1.1 decimal.js-light: 2.5.1 - es-toolkit: 1.39.7 + es-toolkit: 1.39.10 eventemitter3: 5.0.1 - immer: 10.1.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-is: 19.1.0 - react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1) + immer: 10.1.3 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-is: 19.1.1 + react-redux: 9.2.0(@types/react@19.1.12)(react@19.1.1)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 - use-sync-external-store: 1.5.0(react@18.3.1) + use-sync-external-store: 1.5.0(react@19.1.1) victory-vendor: 37.3.6 transitivePeerDependencies: - '@types/react' @@ -5415,30 +5412,31 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rollup@4.45.1: + rollup@4.50.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.45.1 - '@rollup/rollup-android-arm64': 4.45.1 - '@rollup/rollup-darwin-arm64': 4.45.1 - '@rollup/rollup-darwin-x64': 4.45.1 - '@rollup/rollup-freebsd-arm64': 4.45.1 - '@rollup/rollup-freebsd-x64': 4.45.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.45.1 - '@rollup/rollup-linux-arm-musleabihf': 4.45.1 - '@rollup/rollup-linux-arm64-gnu': 4.45.1 - '@rollup/rollup-linux-arm64-musl': 4.45.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.45.1 - '@rollup/rollup-linux-powerpc64le-gnu': 4.45.1 - '@rollup/rollup-linux-riscv64-gnu': 4.45.1 - '@rollup/rollup-linux-riscv64-musl': 4.45.1 - '@rollup/rollup-linux-s390x-gnu': 4.45.1 - '@rollup/rollup-linux-x64-gnu': 4.45.1 - '@rollup/rollup-linux-x64-musl': 4.45.1 - '@rollup/rollup-win32-arm64-msvc': 4.45.1 - '@rollup/rollup-win32-ia32-msvc': 4.45.1 - '@rollup/rollup-win32-x64-msvc': 4.45.1 + '@rollup/rollup-android-arm-eabi': 4.50.1 + '@rollup/rollup-android-arm64': 4.50.1 + '@rollup/rollup-darwin-arm64': 4.50.1 + '@rollup/rollup-darwin-x64': 4.50.1 + '@rollup/rollup-freebsd-arm64': 4.50.1 + '@rollup/rollup-freebsd-x64': 4.50.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 + '@rollup/rollup-linux-arm-musleabihf': 4.50.1 + '@rollup/rollup-linux-arm64-gnu': 4.50.1 + '@rollup/rollup-linux-arm64-musl': 4.50.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 + '@rollup/rollup-linux-ppc64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-musl': 4.50.1 + '@rollup/rollup-linux-s390x-gnu': 4.50.1 + '@rollup/rollup-linux-x64-gnu': 4.50.1 + '@rollup/rollup-linux-x64-musl': 4.50.1 + '@rollup/rollup-openharmony-arm64': 4.50.1 + '@rollup/rollup-win32-arm64-msvc': 4.50.1 + '@rollup/rollup-win32-ia32-msvc': 4.50.1 + '@rollup/rollup-win32-x64-msvc': 4.50.1 fsevents: 2.3.3 rxjs@7.8.2: @@ -5455,10 +5453,6 @@ snapshots: immutable: 4.3.7 source-map-js: 1.2.1 - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - scheduler@0.26.0: {} semver@5.7.2: {} @@ -5516,16 +5510,16 @@ snapshots: spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.21 + spdx-license-ids: 3.0.22 spdx-exceptions@2.5.0: {} spdx-expression-parse@3.0.1: dependencies: spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.21 + spdx-license-ids: 3.0.22 - spdx-license-ids@3.0.21: {} + spdx-license-ids@3.0.22: {} split2@3.2.2: dependencies: @@ -5611,7 +5605,7 @@ snapshots: terser@5.37.0: dependencies: - '@jridgewell/source-map': 0.3.10 + '@jridgewell/source-map': 0.3.11 acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -5720,9 +5714,9 @@ snapshots: tiny-invariant@1.3.3: {} - tinyglobby@0.2.14: + tinyglobby@0.2.15: dependencies: - fdir: 6.4.6(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 to-regex-range@5.0.1: @@ -5749,16 +5743,16 @@ snapshots: typedarray@0.0.6: {} - typesafe-i18n@5.26.2(typescript@5.8.3): + typesafe-i18n@5.26.2(typescript@5.9.2): dependencies: - typescript: 5.8.3 + typescript: 5.9.2 - typescript@5.8.3: {} + typescript@5.9.2: {} uglify-js@3.19.3: optional: true - undici-types@7.8.0: {} + undici-types@7.10.0: {} unified@11.0.5: dependencies: @@ -5793,31 +5787,31 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 - update-browserslist-db@1.1.3(browserslist@4.25.1): + update-browserslist-db@1.1.3(browserslist@4.25.4): dependencies: - browserslist: 4.25.1 + browserslist: 4.25.4 escalade: 3.2.0 picocolors: 1.1.1 - use-breakpoint@4.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + use-breakpoint@4.0.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) - use-context-selector@2.0.0(react@18.3.1)(scheduler@0.26.0): + use-context-selector@2.0.0(react@19.1.1)(scheduler@0.26.0): dependencies: - react: 18.3.1 + react: 19.1.1 scheduler: 0.26.0 - use-deep-compare-effect@1.8.1(react@18.3.1): + use-deep-compare-effect@1.8.1(react@19.1.1): dependencies: - '@babel/runtime': 7.27.6 + '@babel/runtime': 7.28.4 dequal: 2.0.3 - react: 18.3.1 + react: 19.1.1 - use-sync-external-store@1.5.0(react@18.3.1): + use-sync-external-store@1.5.0(react@19.1.1): dependencies: - react: 18.3.1 + react: 19.1.1 util-deprecate@1.0.2: {} @@ -5833,7 +5827,7 @@ snapshots: '@types/unist': 3.0.3 vfile: 6.0.3 - vfile-message@4.0.2: + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 unist-util-stringify-position: 4.0.0 @@ -5841,7 +5835,7 @@ snapshots: vfile@6.0.3: dependencies: '@types/unist': 3.0.3 - vfile-message: 4.0.2 + vfile-message: 4.0.3 victory-vendor@37.3.6: dependencies: @@ -5860,20 +5854,20 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-package-version@1.1.0(vite@7.0.6(@types/node@24.1.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)): + vite-plugin-package-version@1.1.0(vite@7.1.5(@types/node@24.3.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)): dependencies: - vite: 7.0.6(@types/node@24.1.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + vite: 7.1.5(@types/node@24.3.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) - vite@7.0.6(@types/node@24.1.0)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1): + vite@7.1.5(@types/node@24.3.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1): dependencies: - esbuild: 0.25.8 - fdir: 6.4.6(picomatch@4.0.3) + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.45.1 - tinyglobby: 0.2.14 + rollup: 4.50.1 + tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.1.0 + '@types/node': 24.3.1 fsevents: 2.3.3 jiti: 2.4.2 sass: 1.70.0 @@ -5960,11 +5954,11 @@ snapshots: zod@3.25.76: {} - zustand@5.0.6(@types/react@18.3.23)(immer@10.1.1)(react@18.3.1)(use-sync-external-store@1.5.0(react@18.3.1)): + zustand@5.0.8(@types/react@19.1.12)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)): optionalDependencies: - '@types/react': 18.3.23 - immer: 10.1.1 - react: 18.3.1 - use-sync-external-store: 1.5.0(react@18.3.1) + '@types/react': 19.1.12 + immer: 10.1.3 + react: 19.1.1 + use-sync-external-store: 1.5.0(react@19.1.1) zwitch@2.0.4: {} diff --git a/web/src/components/App/App.tsx b/web/src/components/App/App.tsx index d29c694d56..1f05e4b305 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -24,6 +24,7 @@ import { WebhooksListPage } from '../../pages/webhooks/WebhooksListPage'; import { WizardPage } from '../../pages/wizard/WizardPage'; import { PageContainer } from '../../shared/components/Layout/PageContainer/PageContainer'; import { UpgradeLicenseModal } from '../../shared/components/Layout/UpgradeLicenseModal/UpgradeLicenseModal'; +import { OutdatedComponentsModal } from '../../shared/components/modals/OutdatedComponentsModal/OutdatedComponentsModal'; import { UpdateNotificationModal } from '../../shared/components/modals/UpdateNotificationModal/UpdateNotificationModal'; import { ProtectedRoute } from '../../shared/components/Router/Guards/ProtectedRoute/ProtectedRoute'; import { ToastManager } from '../../shared/defguard-ui/components/Layout/ToastManager/ToastManager'; @@ -204,6 +205,7 @@ const App = () => { + diff --git a/web/src/components/AppLoader.tsx b/web/src/components/AppLoader.tsx index 72ad04a9f0..dd78829b13 100644 --- a/web/src/components/AppLoader.tsx +++ b/web/src/components/AppLoader.tsx @@ -5,7 +5,9 @@ import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../i18n/i18n-react'; import { LoaderPage } from '../pages/loader/LoaderPage'; +import { useOutdatedComponentsModal } from '../shared/components/modals/OutdatedComponentsModal/useOutdatedComponentsModal'; import { useToaster } from '../shared/defguard-ui/hooks/toasts/useToaster'; +import { isPresent } from '../shared/defguard-ui/utils/isPresent'; import { useAppStore } from '../shared/hooks/store/useAppStore'; import { useAuthStore } from '../shared/hooks/store/useAuthStore'; import { useUpdatesStore } from '../shared/hooks/store/useUpdatesStore'; @@ -25,12 +27,22 @@ export const AppLoader = () => { const { getAppInfo, getNewVersion, + getOutdatedInfo, user: { getMe }, settings: { getEssentialSettings, getEnterpriseSettings }, } = useApi(); const setAppStore = useAppStore((state) => state.setState); const { LL } = useI18nContext(); const setUpdateStore = useUpdatesStore((s) => s.setUpdate); + const openOutdatedComponentsModal = useOutdatedComponentsModal((s) => s.open); + + const { data: outdatedInfo } = useQuery({ + queryFn: getOutdatedInfo, + queryKey: ['outdated'], + enabled: isPresent(currentUser) && currentUser.is_admin, + refetchOnWindowFocus: false, + refetchOnMount: true, + }); const { data: meData, @@ -136,6 +148,15 @@ export const AppLoader = () => { } }, [newVersionData, setUpdateStore]); + useEffect(() => { + if ( + outdatedInfo && + (outdatedInfo.proxy != null || outdatedInfo.gateways.length > 0) + ) { + openOutdatedComponentsModal(outdatedInfo); + } + }, [outdatedInfo, openOutdatedComponentsModal]); + if (userLoading || (settingsLoading && isUndefined(appSettings))) { return ; } diff --git a/web/src/components/Navigation/components/ApplicationVersion/ApplicationVersion.tsx b/web/src/components/Navigation/components/ApplicationVersion/ApplicationVersion.tsx index d450bb0e9d..462f6f6689 100644 --- a/web/src/components/Navigation/components/ApplicationVersion/ApplicationVersion.tsx +++ b/web/src/components/Navigation/components/ApplicationVersion/ApplicationVersion.tsx @@ -15,20 +15,18 @@ export const ApplicationVersion = ({ isOpen }: Props) => { ); diff --git a/web/src/components/Navigation/components/ApplicationVersion/style.scss b/web/src/components/Navigation/components/ApplicationVersion/style.scss index f5f83f5145..e0dbbd3165 100644 --- a/web/src/components/Navigation/components/ApplicationVersion/style.scss +++ b/web/src/components/Navigation/components/ApplicationVersion/style.scss @@ -8,17 +8,23 @@ justify-content: center; align-content: center; box-sizing: border-box; - padding: 0 1.5rem; - height: 60px; + min-height: 60px; + max-width: 100%; + overflow: hidden; - & > p { + span, + a { @include typography-legacy(10px, 13px, regular, var(--gray-light)); + display: inline-block; + } - & > span, - & > a { - @include typography-legacy(10px, 13px, regular, var(--gray-light)); + p { + @include typography-legacy(10px, 13px, regular, var(--gray-light)); + } - display: inline-block; - } + a { + text-wrap: auto; + word-break: break-word; + text-align: center; } } diff --git a/web/src/components/Navigation/components/NavigationDesktop/NavigationCollapse/NavigationCollapse.tsx b/web/src/components/Navigation/components/NavigationDesktop/NavigationCollapse/NavigationCollapse.tsx index 34d5cde9a9..5eb8dcafe6 100644 --- a/web/src/components/Navigation/components/NavigationDesktop/NavigationCollapse/NavigationCollapse.tsx +++ b/web/src/components/Navigation/components/NavigationDesktop/NavigationCollapse/NavigationCollapse.tsx @@ -1,7 +1,7 @@ import './style.scss'; import classNames from 'classnames'; -import { motion, type TargetAndTransition } from 'framer-motion'; +import { motion, type TargetAndTransition } from 'motion/react'; import { useMemo, useState } from 'react'; import { ColorsRGB } from '../../../../../shared/constants'; diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 32d53a4102..6baa9a6e82 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -61,6 +61,15 @@ const en: BaseTranslation = { }, }, modals: { + outdatedComponentsModal: { + title: 'Version mismatch', + subtitle: 'Defguard detected unsupported version in some components.', + content: { + title: 'Incompatible components:', + unknownVersion: 'Unknown version', + unknownHostname: 'Unknown hostname', + }, + }, upgradeLicenseModal: { enterprise: { title: 'Upgrade to Enterprise', @@ -284,6 +293,12 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do error: 'Failed to start user enrollment', errorDesktop: 'Failed to start desktop activation', }, + messageBox: { + clientForm: + 'You can share the following URL and token with the user to configure their Defguard desktop or mobile client.', + clientQr: + 'You can share this QR code for easy Defguard mobile client configuration.', + }, form: { email: { label: 'Email', @@ -644,10 +659,15 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do }, client: { title: 'Client Activation', + desktopDeepLinkHelp: + 'If you want to configure your Defguard desktop client, please install the client (links below), open it and just press the One-Click Desktop Configuration button', + //md message: - 'Please enter the provided Instance URL and Token into your Defguard Client. You can scan the QR code or copy and paste the token manually.', + 'If you are having trouble with the One-Click configuration you can do it manually by clicking *Add Instance* in the desktop client, and entering the following URL and Token:', qrDescription: "Scan the QR code with your installed Defguard app. If you haven't installed it yet, use your device's app store or the link below.", + qrHelp: + 'If you want to configure your Mobile Defguard Client, please just scan this QR code in the app:', desktopDownload: 'Download for Desktop', tokenCopy: 'Token copied to clipboard', tokenFailure: 'Failed to prepare client setup', @@ -691,7 +711,7 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do title: 'Create VPN device', infoMessage: `

- You need to configure WireGuardVPN on your device, please visit  + You need to configure WireGuard® VPN on your device, please visit  documentation if you don't know how to do it.

`, @@ -1093,6 +1113,7 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do }, }, components: { + openClientDeepLink: 'One-Click Desktop Configuration', aclDefaultPolicySelect: { label: 'Default ACL Policy', options: { @@ -1404,6 +1425,11 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do helper: "Client private key for the Okta directory sync application in the JWK format. It won't be shown again here.", }, + jumpcloud_api_key: { + label: 'JumpCloud API Key', + helper: + 'API Key for the JumpCloud directory sync. It will be used to periodically query JumpCloud for user state and group membership changes.', + }, group_match: { label: 'Sync only matching groups', helper: @@ -1986,6 +2012,8 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do helpers: { address: 'Based on this address VPN network address will be defined, eg. 10.10.10.1/24 (and VPN network will be: 10.10.10.0/24). You can optionally specify multiple addresses separated by a comma. The first address is the primary address, and this one will be used for IP address assignment for devices. The other IP addresses are auxiliary and are not managed by Defguard.', + endpoint: + 'Public IP address or domain name to which the remote peers/users will connect to. This address will be used in the configuration for the clients, but Defguard Gateways do not bind to this address.', gateway: 'Gateway public address, used by VPN users to connect', dns: 'Specify the DNS resolvers to query when the wireguard interface is up.', allowedIps: @@ -2024,7 +2052,7 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do label: 'Gateway VPN IP address and netmask', }, endpoint: { - label: 'Gateway address', + label: 'Gateway IP address or domain name', }, allowedIps: { label: 'Allowed Ips', diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index af7be4fe0c..a42774210d 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -222,6 +222,30 @@ type RootTranslation = { } } modals: { + outdatedComponentsModal: { + /** + * V​e​r​s​i​o​n​ ​m​i​s​m​a​t​c​h + */ + title: string + /** + * D​e​f​g​u​a​r​d​ ​d​e​t​e​c​t​e​d​ ​u​n​s​u​p​p​o​r​t​e​d​ ​v​e​r​s​i​o​n​ ​i​n​ ​s​o​m​e​ ​c​o​m​p​o​n​e​n​t​s​. + */ + subtitle: string + content: { + /** + * I​n​c​o​m​p​a​t​i​b​l​e​ ​c​o​m​p​o​n​e​n​t​s​: + */ + title: string + /** + * U​n​k​n​o​w​n​ ​v​e​r​s​i​o​n + */ + unknownVersion: string + /** + * U​n​k​n​o​w​n​ ​h​o​s​t​n​a​m​e + */ + unknownHostname: string + } + } upgradeLicenseModal: { enterprise: { /** @@ -732,6 +756,16 @@ type RootTranslation = { */ errorDesktop: string } + messageBox: { + /** + * Y​o​u​ ​c​a​n​ ​s​h​a​r​e​ ​t​h​e​ ​f​o​l​l​o​w​i​n​g​ ​U​R​L​ ​a​n​d​ ​t​o​k​e​n​ ​w​i​t​h​ ​t​h​e​ ​u​s​e​r​ ​t​o​ ​c​o​n​f​i​g​u​r​e​ ​t​h​e​i​r​ ​D​e​f​g​u​a​r​d​ ​d​e​s​k​t​o​p​ ​o​r​ ​m​o​b​i​l​e​ ​c​l​i​e​n​t​. + */ + clientForm: string + /** + * Y​o​u​ ​c​a​n​ ​s​h​a​r​e​ ​t​h​i​s​ ​Q​R​ ​c​o​d​e​ ​f​o​r​ ​e​a​s​y​ ​D​e​f​g​u​a​r​d​ ​m​o​b​i​l​e​ ​c​l​i​e​n​t​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​. + */ + clientQr: string + } form: { email: { /** @@ -1503,13 +1537,21 @@ type RootTranslation = { */ title: string /** - * P​l​e​a​s​e​ ​e​n​t​e​r​ ​t​h​e​ ​p​r​o​v​i​d​e​d​ ​I​n​s​t​a​n​c​e​ ​U​R​L​ ​a​n​d​ ​T​o​k​e​n​ ​i​n​t​o​ ​y​o​u​r​ ​D​e​f​g​u​a​r​d​ ​C​l​i​e​n​t​.​ ​Y​o​u​ ​c​a​n​ ​s​c​a​n​ ​t​h​e​ ​Q​R​ ​c​o​d​e​ ​o​r​ ​c​o​p​y​ ​a​n​d​ ​p​a​s​t​e​ ​t​h​e​ ​t​o​k​e​n​ ​m​a​n​u​a​l​l​y​. + * I​f​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​c​o​n​f​i​g​u​r​e​ ​y​o​u​r​ ​D​e​f​g​u​a​r​d​ ​d​e​s​k​t​o​p​ ​c​l​i​e​n​t​,​ ​p​l​e​a​s​e​ ​i​n​s​t​a​l​l​ ​t​h​e​ ​c​l​i​e​n​t​ ​(​l​i​n​k​s​ ​b​e​l​o​w​)​,​ ​o​p​e​n​ ​i​t​ ​a​n​d​ ​j​u​s​t​ ​p​r​e​s​s​ ​t​h​e​ ​O​n​e​-​C​l​i​c​k​ ​D​e​s​k​t​o​p​ ​C​o​n​f​i​g​u​r​a​t​i​o​n​ ​b​u​t​t​o​n + */ + desktopDeepLinkHelp: string + /** + * I​f​ ​y​o​u​ ​a​r​e​ ​h​a​v​i​n​g​ ​t​r​o​u​b​l​e​ ​w​i​t​h​ ​t​h​e​ ​O​n​e​-​C​l​i​c​k​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​y​o​u​ ​c​a​n​ ​d​o​ ​i​t​ ​m​a​n​u​a​l​l​y​ ​b​y​ ​c​l​i​c​k​i​n​g​ ​*​A​d​d​ ​I​n​s​t​a​n​c​e​*​ ​i​n​ ​t​h​e​ ​d​e​s​k​t​o​p​ ​c​l​i​e​n​t​,​ ​a​n​d​ ​e​n​t​e​r​i​n​g​ ​t​h​e​ ​f​o​l​l​o​w​i​n​g​ ​U​R​L​ ​a​n​d​ ​T​o​k​e​n​: */ message: string /** * S​c​a​n​ ​t​h​e​ ​Q​R​ ​c​o​d​e​ ​w​i​t​h​ ​y​o​u​r​ ​i​n​s​t​a​l​l​e​d​ ​D​e​f​g​u​a​r​d​ ​a​p​p​.​ ​I​f​ ​y​o​u​ ​h​a​v​e​n​'​t​ ​i​n​s​t​a​l​l​e​d​ ​i​t​ ​y​e​t​,​ ​u​s​e​ ​y​o​u​r​ ​d​e​v​i​c​e​'​s​ ​a​p​p​ ​s​t​o​r​e​ ​o​r​ ​t​h​e​ ​l​i​n​k​ ​b​e​l​o​w​. */ qrDescription: string + /** + * I​f​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​c​o​n​f​i​g​u​r​e​ ​y​o​u​r​ ​M​o​b​i​l​e​ ​D​e​f​g​u​a​r​d​ ​C​l​i​e​n​t​,​ ​p​l​e​a​s​e​ ​j​u​s​t​ ​s​c​a​n​ ​t​h​i​s​ ​Q​R​ ​c​o​d​e​ ​i​n​ ​t​h​e​ ​a​p​p​: + */ + qrHelp: string /** * D​o​w​n​l​o​a​d​ ​f​o​r​ ​D​e​s​k​t​o​p */ @@ -1601,7 +1643,7 @@ type RootTranslation = { /** * ​ ​ ​ ​ ​ ​ ​ ​ ​<​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​Y​o​u​ ​n​e​e​d​ ​t​o​ ​c​o​n​f​i​g​u​r​e​ ​W​i​r​e​G​u​a​r​d​V​P​N​ ​o​n​ ​y​o​u​r​ ​d​e​v​i​c​e​,​ ​p​l​e​a​s​e​ ​v​i​s​i​t​&​n​b​s​p​;​ + ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​Y​o​u​ ​n​e​e​d​ ​t​o​ ​c​o​n​f​i​g​u​r​e​ ​W​i​r​e​G​u​a​r​d​®​ ​V​P​N​ ​o​n​ ​y​o​u​r​ ​d​e​v​i​c​e​,​ ​p​l​e​a​s​e​ ​v​i​s​i​t​&​n​b​s​p​;​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​a​ ​h​r​e​f​=​"​{​a​d​d​D​e​v​i​c​e​s​D​o​c​s​}​"​>​d​o​c​u​m​e​n​t​a​t​i​o​n​<​/​a​>​ ​i​f​ ​y​o​u​ ​d​o​n​&​a​p​o​s​;​t​ ​k​n​o​w​ ​h​o​w​ ​t​o​ ​d​o​ ​i​t​.​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​p​>​ @@ -2693,6 +2735,10 @@ type RootTranslation = { } } components: { + /** + * O​n​e​-​C​l​i​c​k​ ​D​e​s​k​t​o​p​ ​C​o​n​f​i​g​u​r​a​t​i​o​n + */ + openClientDeepLink: string aclDefaultPolicySelect: { /** * D​e​f​a​u​l​t​ ​A​C​L​ ​P​o​l​i​c​y @@ -3448,6 +3494,16 @@ type RootTranslation = { */ helper: string } + jumpcloud_api_key: { + /** + * J​u​m​p​C​l​o​u​d​ ​A​P​I​ ​K​e​y + */ + label: string + /** + * A​P​I​ ​K​e​y​ ​f​o​r​ ​t​h​e​ ​J​u​m​p​C​l​o​u​d​ ​d​i​r​e​c​t​o​r​y​ ​s​y​n​c​.​ ​I​t​ ​w​i​l​l​ ​b​e​ ​u​s​e​d​ ​t​o​ ​p​e​r​i​o​d​i​c​a​l​l​y​ ​q​u​e​r​y​ ​J​u​m​p​C​l​o​u​d​ ​f​o​r​ ​u​s​e​r​ ​s​t​a​t​e​ ​a​n​d​ ​g​r​o​u​p​ ​m​e​m​b​e​r​s​h​i​p​ ​c​h​a​n​g​e​s​. + */ + helper: string + } group_match: { /** * S​y​n​c​ ​o​n​l​y​ ​m​a​t​c​h​i​n​g​ ​g​r​o​u​p​s @@ -4774,6 +4830,10 @@ type RootTranslation = { * B​a​s​e​d​ ​o​n​ ​t​h​i​s​ ​a​d​d​r​e​s​s​ ​V​P​N​ ​n​e​t​w​o​r​k​ ​a​d​d​r​e​s​s​ ​w​i​l​l​ ​b​e​ ​d​e​f​i​n​e​d​,​ ​e​g​.​ ​1​0​.​1​0​.​1​0​.​1​/​2​4​ ​(​a​n​d​ ​V​P​N​ ​n​e​t​w​o​r​k​ ​w​i​l​l​ ​b​e​:​ ​1​0​.​1​0​.​1​0​.​0​/​2​4​)​.​ ​Y​o​u​ ​c​a​n​ ​o​p​t​i​o​n​a​l​l​y​ ​s​p​e​c​i​f​y​ ​m​u​l​t​i​p​l​e​ ​a​d​d​r​e​s​s​e​s​ ​s​e​p​a​r​a​t​e​d​ ​b​y​ ​a​ ​c​o​m​m​a​.​ ​T​h​e​ ​f​i​r​s​t​ ​a​d​d​r​e​s​s​ ​i​s​ ​t​h​e​ ​p​r​i​m​a​r​y​ ​a​d​d​r​e​s​s​,​ ​a​n​d​ ​t​h​i​s​ ​o​n​e​ ​w​i​l​l​ ​b​e​ ​u​s​e​d​ ​f​o​r​ ​I​P​ ​a​d​d​r​e​s​s​ ​a​s​s​i​g​n​m​e​n​t​ ​f​o​r​ ​d​e​v​i​c​e​s​.​ ​T​h​e​ ​o​t​h​e​r​ ​I​P​ ​a​d​d​r​e​s​s​e​s​ ​a​r​e​ ​a​u​x​i​l​i​a​r​y​ ​a​n​d​ ​a​r​e​ ​n​o​t​ ​m​a​n​a​g​e​d​ ​b​y​ ​D​e​f​g​u​a​r​d​. */ address: string + /** + * P​u​b​l​i​c​ ​I​P​ ​a​d​d​r​e​s​s​ ​o​r​ ​d​o​m​a​i​n​ ​n​a​m​e​ ​t​o​ ​w​h​i​c​h​ ​t​h​e​ ​r​e​m​o​t​e​ ​p​e​e​r​s​/​u​s​e​r​s​ ​w​i​l​l​ ​c​o​n​n​e​c​t​ ​t​o​.​ ​T​h​i​s​ ​a​d​d​r​e​s​s​ ​w​i​l​l​ ​b​e​ ​u​s​e​d​ ​i​n​ ​t​h​e​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​f​o​r​ ​t​h​e​ ​c​l​i​e​n​t​s​,​ ​b​u​t​ ​D​e​f​g​u​a​r​d​ ​G​a​t​e​w​a​y​s​ ​d​o​ ​n​o​t​ ​b​i​n​d​ ​t​o​ ​t​h​i​s​ ​a​d​d​r​e​s​s​. + */ + endpoint: string /** * G​a​t​e​w​a​y​ ​p​u​b​l​i​c​ ​a​d​d​r​e​s​s​,​ ​u​s​e​d​ ​b​y​ ​V​P​N​ ​u​s​e​r​s​ ​t​o​ ​c​o​n​n​e​c​t */ @@ -4852,7 +4912,7 @@ type RootTranslation = { } endpoint: { /** - * G​a​t​e​w​a​y​ ​a​d​d​r​e​s​s + * G​a​t​e​w​a​y​ ​I​P​ ​a​d​d​r​e​s​s​ ​o​r​ ​d​o​m​a​i​n​ ​n​a​m​e */ label: string } @@ -6869,6 +6929,30 @@ export type TranslationFunctions = { } } modals: { + outdatedComponentsModal: { + /** + * Version mismatch + */ + title: () => LocalizedString + /** + * Defguard detected unsupported version in some components. + */ + subtitle: () => LocalizedString + content: { + /** + * Incompatible components: + */ + title: () => LocalizedString + /** + * Unknown version + */ + unknownVersion: () => LocalizedString + /** + * Unknown hostname + */ + unknownHostname: () => LocalizedString + } + } upgradeLicenseModal: { enterprise: { /** @@ -7372,6 +7456,16 @@ export type TranslationFunctions = { */ errorDesktop: () => LocalizedString } + messageBox: { + /** + * You can share the following URL and token with the user to configure their Defguard desktop or mobile client. + */ + clientForm: () => LocalizedString + /** + * You can share this QR code for easy Defguard mobile client configuration. + */ + clientQr: () => LocalizedString + } form: { email: { /** @@ -8131,13 +8225,21 @@ export type TranslationFunctions = { */ title: () => LocalizedString /** - * Please enter the provided Instance URL and Token into your Defguard Client. You can scan the QR code or copy and paste the token manually. + * If you want to configure your Defguard desktop client, please install the client (links below), open it and just press the One-Click Desktop Configuration button + */ + desktopDeepLinkHelp: () => LocalizedString + /** + * If you are having trouble with the One-Click configuration you can do it manually by clicking *Add Instance* in the desktop client, and entering the following URL and Token: */ message: () => LocalizedString /** * Scan the QR code with your installed Defguard app. If you haven't installed it yet, use your device's app store or the link below. */ qrDescription: () => LocalizedString + /** + * If you want to configure your Mobile Defguard Client, please just scan this QR code in the app: + */ + qrHelp: () => LocalizedString /** * Download for Desktop */ @@ -8229,7 +8331,7 @@ export type TranslationFunctions = { /** *

- You need to configure WireGuardVPN on your device, please visit  + You need to configure WireGuard® VPN on your device, please visit  documentation if you don't know how to do it.

@@ -9309,6 +9411,10 @@ export type TranslationFunctions = { } } components: { + /** + * One-Click Desktop Configuration + */ + openClientDeepLink: () => LocalizedString aclDefaultPolicySelect: { /** * Default ACL Policy @@ -10059,6 +10165,16 @@ export type TranslationFunctions = { */ helper: () => LocalizedString } + jumpcloud_api_key: { + /** + * JumpCloud API Key + */ + label: () => LocalizedString + /** + * API Key for the JumpCloud directory sync. It will be used to periodically query JumpCloud for user state and group membership changes. + */ + helper: () => LocalizedString + } group_match: { /** * Sync only matching groups @@ -11369,6 +11485,10 @@ export type TranslationFunctions = { * Based on this address VPN network address will be defined, eg. 10.10.10.1/24 (and VPN network will be: 10.10.10.0/24). You can optionally specify multiple addresses separated by a comma. The first address is the primary address, and this one will be used for IP address assignment for devices. The other IP addresses are auxiliary and are not managed by Defguard. */ address: () => LocalizedString + /** + * Public IP address or domain name to which the remote peers/users will connect to. This address will be used in the configuration for the clients, but Defguard Gateways do not bind to this address. + */ + endpoint: () => LocalizedString /** * Gateway public address, used by VPN users to connect */ @@ -11447,7 +11567,7 @@ export type TranslationFunctions = { } endpoint: { /** - * Gateway address + * Gateway IP address or domain name */ label: () => LocalizedString } diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 61277e4ccb..66c665ffae 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -1222,6 +1222,11 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe helper: 'Klucz prywatny dla aplikacji synchronizacji Okta w formacie JWK. Klucz nie jest wyświetlany ponownie po wgraniu.', }, + jumpcloud_api_key: { + label: 'Klucz API JumpCloud', + helper: + 'Klucz API JumpCloud używany do synchronizacji stanu użytkowników i grup.', + }, group_match: { label: 'Synchronizuj tylko pasujące grupy', helper: @@ -1771,6 +1776,8 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe helpers: { address: 'Na podstawie tego adresu będzie stworzona sieć VPN, np. 10.10.10.1/24 (sieć VPN: 10.10.10.0/24). Opcjonalnie możesz podać wiele adresów, oddzielając je przecinkiem. Pierwszy adres będzie adresem głównym i zostanie użyty do przypisywania adresów IP urządzeniom. Pozostałe adresy są dodatkowe i nie będą zarządzane przez Defguarda.', + endpoint: + 'Publiczny adres IP lub domena internetowa, do której będą łączyć się użytkownicy/urządzenia. Ten adres zostanie użyty w konfiguracji klientów, ale Gatewaye Defguard nie wiążą się z tym adresem.', gateway: 'Adres publiczny Gatewaya, używany przez użytkowników VPN do łączenia się.', dns: 'Określ resolwery DNS, które mają odpytywać, gdy interfejs WireGuard jest aktywny.', @@ -1790,7 +1797,7 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe label: 'Adres i maska sieci VPN', }, endpoint: { - label: 'Adres gatewaya', + label: 'Adres IP lub domena internetowa Gatewaya', }, allowedIps: { label: 'Dozwolone adresy IP', diff --git a/web/src/pages/acl/AclCreateDataProvider.tsx b/web/src/pages/acl/AclCreateDataProvider.tsx index 23ad7ceb38..b51a93341c 100644 --- a/web/src/pages/acl/AclCreateDataProvider.tsx +++ b/web/src/pages/acl/AclCreateDataProvider.tsx @@ -36,7 +36,7 @@ export const AclCreateDataProvider = ({ children }: Props) => { const editRuleId = useMemo(() => { if (isRuleEdit) { - return parseInt(searchParams.get('rule') as string); + return parseInt(searchParams.get('rule') as string, 10); } }, [isRuleEdit, searchParams]); diff --git a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AlcAliasCEModal/AlcAliasCEModal.tsx b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AlcAliasCEModal/AlcAliasCEModal.tsx index 7eb64c9ec8..6a68bf7900 100644 --- a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AlcAliasCEModal/AlcAliasCEModal.tsx +++ b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AlcAliasCEModal/AlcAliasCEModal.tsx @@ -84,6 +84,7 @@ const ModalContent = () => { .string({ required_error: formErrors.required(), }) + .trim() .min(1, formErrors.required()), kind: z.nativeEnum(AclAliasKind), ports: aclPortsValidator(LL), diff --git a/web/src/pages/acl/validators.ts b/web/src/pages/acl/validators.ts index e8b76e04f9..83151eb687 100644 --- a/web/src/pages/acl/validators.ts +++ b/web/src/pages/acl/validators.ts @@ -23,7 +23,7 @@ export const aclPortsValidator = (LL: TranslationFunctions) => .filter((v) => v !== ''); const found: number[] = []; for (const entry of trimmed) { - const num = parseInt(entry); + const num = parseInt(entry, 10); if (Number.isNaN(num)) { return false; } @@ -86,7 +86,7 @@ function parseSubnet(input: string): [ipaddr.IPv4 | ipaddr.IPv6, number] | null const kind = ip.kind(); if (kind === 'ipv6') { - const prefix = parseInt(maskPart); + const prefix = parseInt(maskPart, 10); if (typeof prefix !== 'number' || Number.isNaN(prefix)) { return null; } diff --git a/web/src/pages/activity-log/ActivityLogPage.tsx b/web/src/pages/activity-log/ActivityLogPage.tsx index 5adf5fb319..7f1b42b45f 100644 --- a/web/src/pages/activity-log/ActivityLogPage.tsx +++ b/web/src/pages/activity-log/ActivityLogPage.tsx @@ -9,7 +9,6 @@ import Skeleton from 'react-loading-skeleton'; import { useI18nContext } from '../../i18n/i18n-react'; import { FilterButton } from '../../shared/components/Layout/buttons/FilterButton/FilterButton'; import { PageContainer } from '../../shared/components/Layout/PageContainer/PageContainer'; -import { PageLimiter } from '../../shared/components/Layout/PageLimiter/PageLimiter'; import { FilterGroupsModal } from '../../shared/components/modals/FilterGroupsModal/FilterGroupsModal'; import type { FilterGroupsModalFilter } from '../../shared/components/modals/FilterGroupsModal/types'; import { Button } from '../../shared/defguard-ui/components/Layout/Button/Button'; @@ -34,10 +33,8 @@ import { export const ActivityLogPage = () => { return ( - - - - + + ); }; diff --git a/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/AddDeviceClientConfigurationStep.tsx b/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/AddDeviceClientConfigurationStep.tsx index 80a1acdf4a..eaf9bf3914 100644 --- a/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/AddDeviceClientConfigurationStep.tsx +++ b/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/AddDeviceClientConfigurationStep.tsx @@ -5,6 +5,8 @@ import QRCode from 'react-qr-code'; import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../../i18n/i18n-react'; +import { OpenDesktopClientButton } from '../../../../shared/components/Layout/buttons/OpenDesktopClientButton/OpenDesktopClientButton'; +import { RenderMarkdown } from '../../../../shared/components/Layout/RenderMarkdown/RenderMarkdown'; import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; import { ButtonSize, @@ -24,6 +26,7 @@ export const AddDeviceClientConfigurationStep = () => { const { LL } = useI18nContext(); const localLL = LL.addDevicePage.steps.client; const clientData = useAddDevicePageStore((s) => s.clientSetup); + const clientSetup = useAddDevicePageStore((s) => s.clientSetup); const tokenValue = useAddDevicePageStore((s) => s.clientSetup ? enrollmentToImportToken(s.clientSetup.url, s.clientSetup.token) @@ -43,7 +46,17 @@ export const AddDeviceClientConfigurationStep = () => { return (

{localLL.title()}

- + {isPresent(clientSetup) && ( + <> + +
+ +
+ + )} + + + {/* { void writeToClipboard(value, localLL.tokenCopy()); }} /> +
diff --git a/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/style.scss b/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/style.scss index 5eda9e88be..df62be874d 100644 --- a/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/style.scss +++ b/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/style.scss @@ -44,7 +44,7 @@ flex-flow: row; align-items: center; justify-content: center; - padding: var(--spacing-l) var(--spacing-s); + padding: var(--spacing-m) var(--spacing-s) var(--spacing-l); box-sizing: border-box; } } diff --git a/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/AddDeviceSetupMethodStep.tsx b/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/AddDeviceSetupMethodStep.tsx index cef27fbc84..26aa2eb03f 100644 --- a/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/AddDeviceSetupMethodStep.tsx +++ b/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/AddDeviceSetupMethodStep.tsx @@ -75,9 +75,8 @@ export const AddDeviceSetupMethodStep = () => { setupMethod === AddDeviceStep.NATIVE_CHOOSE_METHOD ) { setSetupMethod(AddDeviceStep.CLIENT_CONFIGURATION); - startActivation(); } - }, [enterpriseSettings?.only_client_activation, setupMethod, startActivation]); + }, [enterpriseSettings?.only_client_activation, setupMethod]); return ( <> @@ -94,6 +93,7 @@ export const AddDeviceSetupMethodStep = () => { }} /> { diff --git a/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/DeviceSetupMethodCard.tsx b/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/DeviceSetupMethodCard.tsx index 69ffd43eeb..cb1dbecc57 100644 --- a/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/DeviceSetupMethodCard.tsx +++ b/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/DeviceSetupMethodCard.tsx @@ -16,20 +16,27 @@ type StandaloneConfig = { }; type Props = { - methodType?: DeviceSetupMethod; - custom?: StandaloneConfig; active: boolean; onClick: () => void; + methodType?: DeviceSetupMethod; + custom?: StandaloneConfig; + disabled?: boolean; }; type ContentConfiguration = { title: string; description: string; testId: string; -} & Pick & +} & Pick & PropsWithChildren; -export const DeviceSetupMethodCard = ({ methodType, active, onClick, custom }: Props) => { +export const DeviceSetupMethodCard = ({ + methodType, + active, + onClick, + custom, + disabled = false, +}: Props) => { const { LL } = useI18nContext(); const localLL = LL.addDevicePage.steps.setupMethod.methods; @@ -58,6 +65,7 @@ export const DeviceSetupMethodCard = ({ methodType, active, onClick, custom }: P onClick={onClick} testId={testId} title={title} + disabled={disabled} > {methodType === DeviceSetupMethod.CLIENT && ( <> @@ -92,15 +100,21 @@ const Content = ({ testId, title, children, + disabled = false, }: ContentConfiguration) => { return (
{ + if (!disabled) { + onClick?.(); + } + }} >

{title}

{description}

diff --git a/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/style.scss b/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/style.scss index 004d82a6ba..b48c11c671 100644 --- a/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/style.scss +++ b/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/style.scss @@ -9,13 +9,14 @@ border-radius: 15px; padding: var(--spacing-l) var(--spacing-s); box-shadow: var(--box-shadow); + opacity: 1; cursor: pointer; outline: 0px solid transparent; - transition-property: outline; + transition-property: outline, opacity; @include animate-standard; - &:not(.active):hover { + &:not(.active):not(.disabled):hover { outline: 1px solid var(--surface-main-primary); } @@ -23,6 +24,11 @@ outline: 3px solid var(--surface-main-primary); } + &.disabled { + cursor: not-allowed; + opacity: 0.5; + } + .title { text-align: center; padding-bottom: var(--spacing-s); diff --git a/web/src/pages/addDevice/steps/AddDeviceSetupStep/AddDeviceSetupStep.tsx b/web/src/pages/addDevice/steps/AddDeviceSetupStep/AddDeviceSetupStep.tsx index 466498ea86..f71c05c166 100644 --- a/web/src/pages/addDevice/steps/AddDeviceSetupStep/AddDeviceSetupStep.tsx +++ b/web/src/pages/addDevice/steps/AddDeviceSetupStep/AddDeviceSetupStep.tsx @@ -65,11 +65,12 @@ export const AddDeviceSetupStep = () => { choice: z.nativeEnum(AddNativeWgDeviceMode), name: z .string() + .trim() .min(4, LL.form.error.minimumLength()) .refine((val) => !userData?.reservedDevices?.includes(val), { message: localLL.form.errors.name.duplicatedName(), }), - publicKey: z.string(), + publicKey: z.string().trim(), }) .superRefine((val, ctx) => { const { publicKey, choice } = val; diff --git a/web/src/pages/addDevice/utils/enrollmentToToken.ts b/web/src/pages/addDevice/utils/enrollmentToToken.ts index b926e816e1..5d3cdf52d5 100644 --- a/web/src/pages/addDevice/utils/enrollmentToToken.ts +++ b/web/src/pages/addDevice/utils/enrollmentToToken.ts @@ -5,33 +5,10 @@ export type EnrollmentData = { token: string; }; -const useLocalProxy = import.meta.env.DEV; - -const extractProxyPort = (input: string): string | undefined => { - try { - const url = new URL(input); - const port = url.port; - const parsed = port ? parseInt(port, 10) : undefined; - if (parsed && !Number.isNaN(parsed)) { - return `:${parsed}`; - } - return undefined; - } catch { - return undefined; - } -}; - export const enrollmentToImportToken = (url: string, token: string): string => { - let proxyUrl: string; - if (useLocalProxy) { - const port = extractProxyPort(url); - proxyUrl = `http://10.0.2.2${port}`; - } else { - proxyUrl = url; - } const data: EnrollmentData = { token, - url: proxyUrl, + url, }; const jsonString = JSON.stringify(data); const textEncoder = new TextEncoder(); diff --git a/web/src/pages/auth/AuthPage.tsx b/web/src/pages/auth/AuthPage.tsx index 9ccf95bd53..5a81371aef 100644 --- a/web/src/pages/auth/AuthPage.tsx +++ b/web/src/pages/auth/AuthPage.tsx @@ -1,6 +1,6 @@ import './style.scss'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Navigate, Route, Routes, useNavigate, useSearchParams } from 'react-router-dom'; import { shallow } from 'zustand/shallow'; @@ -17,6 +17,21 @@ import { Login } from './Login/Login'; import { MFARoute } from './MFARoute/MFARoute'; import { useMFAStore } from './shared/hooks/useMFAStore'; +const VALID_URL_PATTERN = + /^(https?:\/\/[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+(?::[0-9]{1,5})?(?:\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@/]*)?(?:\?[a-zA-Z0-9\-._~%!$&'()*+,;=:@/?]*)?|\/[a-zA-Z0-9\-._~%!$&'()*+,;=:@/]*(?:\?[a-zA-Z0-9\-._~%!$&'()*+,;=:@/?]*)?)$/gi; + +// Return redirect URL only if it matches a safe pattern: +// - starts with http/https +// - contains only safe characters (no <, >) +// - can include query params +// +// Once a URL matches this pattern we also explicitly check for unsafe elements in case they are a part of redirect URL query params +const sanitizeRedirectUrl = (url: string | null) => { + if (url?.match(VALID_URL_PATTERN) && !/javascript:|data:|\\/.test(url)) return url; + + return null; +}; + export const AuthPage = () => { const { getAppInfo, @@ -49,7 +64,7 @@ export const AuthPage = () => { const setAppStore = useAppStore((state) => state.setState); const [params] = useSearchParams(); - const redirectUrl = params.get('r'); + const redirectUrl = useMemo(() => sanitizeRedirectUrl(params.get('r')), [params]); useEffect(() => { if (user && (!mfaMethod || mfaMethod === UserMFAMethod.NONE) && !openIdParams) { diff --git a/web/src/pages/auth/Login/Login.tsx b/web/src/pages/auth/Login/Login.tsx index 0f25007922..6e55a34dff 100644 --- a/web/src/pages/auth/Login/Login.tsx +++ b/web/src/pages/auth/Login/Login.tsx @@ -56,11 +56,13 @@ export const Login = () => { z.object({ username: z .string() + .trim() .min(1, LL.form.error.minimumLength()) .max(64) .regex(patternLoginCharacters, LL.form.error.forbiddenCharacter()), password: z .string() + .trim() .min(1, LL.form.error.required()) .max(128, LL.form.error.maximumLength()), }), diff --git a/web/src/pages/auth/MFARoute/MFAEmail/MFAEmail.tsx b/web/src/pages/auth/MFARoute/MFAEmail/MFAEmail.tsx index 73fad33547..8bc85d6afe 100644 --- a/web/src/pages/auth/MFARoute/MFAEmail/MFAEmail.tsx +++ b/web/src/pages/auth/MFARoute/MFAEmail/MFAEmail.tsx @@ -57,6 +57,7 @@ export const MFAEmail = () => { z.object({ code: z .string() + .trim() .min(6, LL.form.error.minimumLength()) .max(6, LL.form.error.maximumLength()) .regex(patternNumbersOnly, LL.form.error.invalid()), diff --git a/web/src/pages/auth/MFARoute/MFATOTPAuth/MFATOTPAuth.tsx b/web/src/pages/auth/MFARoute/MFATOTPAuth/MFATOTPAuth.tsx index d3a5432b1a..bd3ce1b3ae 100644 --- a/web/src/pages/auth/MFARoute/MFATOTPAuth/MFATOTPAuth.tsx +++ b/web/src/pages/auth/MFARoute/MFATOTPAuth/MFATOTPAuth.tsx @@ -51,6 +51,7 @@ export const MFATOTPAuth = () => { z.object({ code: z .string() + .trim() .min(6, LL.form.error.validCode()) .max(6, LL.form.error.validCode()), }), diff --git a/web/src/pages/devices/components/DevicesList/DevicesList.tsx b/web/src/pages/devices/components/DevicesList/DevicesList.tsx index 12a8cda5f4..00ce1de11b 100644 --- a/web/src/pages/devices/components/DevicesList/DevicesList.tsx +++ b/web/src/pages/devices/components/DevicesList/DevicesList.tsx @@ -1,7 +1,6 @@ import './style.scss'; import { useMutation } from '@tanstack/react-query'; -import dayjs from 'dayjs'; import { useCallback, useMemo } from 'react'; import { shallow } from 'zustand/shallow'; @@ -22,6 +21,7 @@ import useApi from '../../../../shared/hooks/useApi'; import { useClipboard } from '../../../../shared/hooks/useClipboard'; import { useToaster } from '../../../../shared/hooks/useToaster'; import type { StandaloneDevice } from '../../../../shared/types'; +import { dateToLocal } from '../../../../shared/utils/displayDate'; import type { ListCellTag } from '../../../acl/AclIndexPage/components/shared/types'; import { useDeleteStandaloneDeviceModal } from '../../hooks/useDeleteStandaloneDeviceModal'; import { useDevicesPage } from '../../hooks/useDevicesPage'; @@ -96,9 +96,10 @@ const DeviceRow = (props: StandaloneDevice) => { [assigned_ips], ); const formatDate = useMemo(() => { - const day = dayjs(added_date); + const day = dateToLocal(added_date); return day.format('DD.MM.YYYY | HH:mm'); }, [added_date]); + const { writeToClipboard } = useClipboard(); return (
diff --git a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx index c9246dbba4..acc2c31a7f 100644 --- a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx +++ b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx @@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; import type { Subject } from 'rxjs'; -import { z } from 'zod'; import { useI18nContext } from '../../../../../i18n/i18n-react'; import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; @@ -19,12 +18,15 @@ import type { ToggleOption } from '../../../../../shared/defguard-ui/components/ import useApi from '../../../../../shared/hooks/useApi'; import { useToaster } from '../../../../../shared/hooks/useToaster'; import type { GetAvailableLocationIpResponse } from '../../../../../shared/types'; -import { validateWireguardPublicKey } from '../../../../../shared/validators'; import { type AddStandaloneDeviceFormFields, WGConfigGenChoice, } from '../../AddStandaloneDeviceModal/types'; import { StandaloneDeviceModalFormMode } from '../types'; +import { + type StandaloneDeviceFormFields, + standaloneDeviceFormSchema, +} from './formSchema'; type Props = { onSubmit: (formValues: AddStandaloneDeviceFormFields) => Promise; @@ -32,7 +34,7 @@ type Props = { onLoadingChange: (value: boolean) => void; locationOptions: SelectOption[]; submitSubject: Subject; - defaults: AddStandaloneDeviceFormFields; + defaults: StandaloneDeviceFormFields; reservedNames: string[]; initialIpRecommendation: GetAvailableLocationIpResponse; }; @@ -57,7 +59,6 @@ export const StandaloneDeviceModalForm = ({ // auto assign upon location change is happening const [ipIsLoading, setIpIsLoading] = useState(false); const localLL = LL.modals.addStandaloneDevice.form; - const errors = LL.form.error; const labels = localLL.labels; const submitRef = useRef(null); const toaster = useToaster(); @@ -99,46 +100,12 @@ export const StandaloneDeviceModalForm = ({ const schema = useMemo( () => - z - .object({ - name: z - .string() - .min(1, LL.form.error.required()) - .refine((value) => { - if (mode === StandaloneDeviceModalFormMode.EDIT) { - const filtered = reservedNames.filter((n) => n !== defaults.name.trim()); - return !filtered.includes(value.trim()); - } - return !reservedNames.includes(value.trim()); - }, LL.form.error.reservedName()), - location_id: z.number(), - description: z.string().optional(), - modifiableIpParts: z.array(z.string().min(1, LL.form.error.required())), - generationChoice: z.nativeEnum(WGConfigGenChoice), - wireguard_pubkey: z.string().optional(), - }) - .superRefine((vals, ctx) => { - if (mode === StandaloneDeviceModalFormMode.CREATE_MANUAL) { - if (vals.generationChoice === WGConfigGenChoice.MANUAL) { - const result = validateWireguardPublicKey({ - requiredError: errors.required(), - maxError: errors.maximumLengthOf({ length: 44 }), - minError: errors.minimumLengthOf({ length: 44 }), - validKeyError: errors.invalid(), - }).safeParse(vals.wireguard_pubkey); - if (!result.success) { - result.error.errors.forEach((e) => { - ctx.addIssue({ - path: ['wireguard_pubkey'], - message: e.message, - code: 'custom', - }); - }); - } - } - } - }), - [LL.form.error, defaults.name, errors, mode, reservedNames], + standaloneDeviceFormSchema(LL, { + mode, + reservedNames, + originalName: defaults.name, + }), + [mode, reservedNames, defaults.name, LL], ); const { @@ -156,54 +123,62 @@ export const StandaloneDeviceModalForm = ({ const generationChoiceValue = watch('generationChoice'); - function newIps(formIps: string[]): string[] { - const initialIpsSet = new Set( - initialIpRecommendation.map((ip) => ip.network_part + ip.modifiable_part), - ); - const formIpsSet = new Set(formIps); - return Array.from(formIpsSet.difference(initialIpsSet)); - } - const submitHandler: SubmitHandler = async ( - formValues, - ) => { + const submitHandler: SubmitHandler = async (formValues) => { const values = formValues; - const { modifiableIpParts: modifiableIpPart } = values; - values.description = values.description?.trim(); - values.name = values.name.trim(); - const currentIpResp = internalRecommendedIps ?? initialIpRecommendation; - values.modifiableIpParts = currentIpResp.map( - (resp, i) => resp.network_part + formValues.modifiableIpParts[i].trim(), - ); - if ( - mode === StandaloneDeviceModalFormMode.EDIT && - modifiableIpPart === defaults.modifiableIpParts - ) { - await onSubmit(values); - return; + const recommendationResponse = internalRecommendedIps ?? initialIpRecommendation; + let validationList = recommendationResponse.map((recommendation, index) => ({ + ip: recommendation.network_part + formValues.modifiableIpParts[index], + index, + })); + values.modifiableIpParts = validationList.map((item) => item.ip); + // try to validate explicitly chosen IPs before submission + let validationErrors = false; + + // if edit exclude initial ip's from validation as they are reserved already by edited device + if (mode === StandaloneDeviceModalFormMode.EDIT) { + const reservedByDevice = initialIpRecommendation.map( + (item) => item.network_part + item.modifiable_part, + ); + validationList = validationList.filter( + (item) => !reservedByDevice.includes(item.ip), + ); } - try { - const response = await validateLocationIp({ - ips: newIps(values.modifiableIpParts), - location: values.location_id, - }); - const { available, valid } = response; - if (available && valid) { + + if (validationList.length) { + try { + const response = await validateLocationIp({ + ips: validationList.map((item) => item.ip), + location: values.location_id, + }); + + response.forEach(({ available, valid }, index) => { + const fieldIndex = validationList[index].index; + if (!available) { + validationErrors = true; + setError(`modifiableIpParts.${fieldIndex}`, { + message: LL.form.error.reservedIp(), + }); + } + if (!valid) { + validationErrors = true; + setError(`modifiableIpParts.${fieldIndex}`, { + message: LL.form.error.invalidIp(), + }); + } + }); + } catch (_) { + validationErrors = true; + toaster.error(LL.messages.error()); + } + } + + // submit form if no validation errors occurred + if (!validationErrors) { + try { await onSubmit(values); - } else { - if (!available) { - setError('modifiableIpParts', { - message: LL.form.error.reservedIp(), - }); - } - if (!valid) { - setError('modifiableIpParts', { - message: LL.form.error.invalidIp(), - }); - } + } catch (_) { + toaster.error(LL.messages.error()); } - } catch (e) { - toaster.error(LL.messages.error()); - console.error(e); } }; diff --git a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/formSchema.ts b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/formSchema.ts new file mode 100644 index 0000000000..e43a1aaaf9 --- /dev/null +++ b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/formSchema.ts @@ -0,0 +1,64 @@ +import z from 'zod'; +import type { TranslationFunctions } from '../../../../../i18n/i18n-types'; +import { isPresent } from '../../../../../shared/defguard-ui/utils/isPresent'; +import { validateWireguardPublicKey } from '../../../../../shared/validators'; +import { WGConfigGenChoice } from '../../AddStandaloneDeviceModal/types'; +import { StandaloneDeviceModalFormMode } from '../types'; + +type SchemaProps = { + mode: StandaloneDeviceModalFormMode; + reservedNames: string[]; + originalName?: string; +}; + +export const standaloneDeviceFormSchema = ( + LL: TranslationFunctions, + { mode, reservedNames, originalName }: SchemaProps, +) => { + const errors = LL.form.error; + + return z + .object({ + name: z + .string() + .trim() + .min(1, LL.form.error.required()) + .refine((value) => { + if (mode === StandaloneDeviceModalFormMode.EDIT && isPresent(originalName)) { + const filtered = reservedNames.filter((n) => n !== originalName.trim()); + return !filtered.includes(value.trim()); + } + return !reservedNames.includes(value.trim()); + }, LL.form.error.reservedName()), + location_id: z.number(), + description: z.string().trim().optional(), + modifiableIpParts: z.array(z.string().trim().min(1, LL.form.error.required())), + generationChoice: z.nativeEnum(WGConfigGenChoice), + wireguard_pubkey: z.string().trim().optional(), + }) + .superRefine((vals, ctx) => { + if (mode === StandaloneDeviceModalFormMode.CREATE_MANUAL) { + if (vals.generationChoice === WGConfigGenChoice.MANUAL) { + const result = validateWireguardPublicKey({ + requiredError: errors.required(), + maxError: errors.maximumLengthOf({ length: 44 }), + minError: errors.minimumLengthOf({ length: 44 }), + validKeyError: errors.invalid(), + }).safeParse(vals.wireguard_pubkey); + if (!result.success) { + result.error.errors.forEach((e) => { + ctx.addIssue({ + path: ['wireguard_pubkey'], + message: e.message, + code: 'custom', + }); + }); + } + } + } + }); +}; + +export type StandaloneDeviceFormFields = z.infer< + ReturnType +>; diff --git a/web/src/pages/groups/components/modals/AddGroupModal/AddGroupModal.tsx b/web/src/pages/groups/components/modals/AddGroupModal/AddGroupModal.tsx index d453a99ea3..2dc07e45d4 100644 --- a/web/src/pages/groups/components/modals/AddGroupModal/AddGroupModal.tsx +++ b/web/src/pages/groups/components/modals/AddGroupModal/AddGroupModal.tsx @@ -114,6 +114,7 @@ const ModalContent = () => { .string({ required_error: LL.form.error.required(), }) + .trim() .min(4, LL.form.error.minimumLength()) .refine((name) => { // if in edit mode ignore self name diff --git a/web/src/pages/loader/style.scss b/web/src/pages/loader/style.scss index a9eaa8b511..ec84297561 100644 --- a/web/src/pages/loader/style.scss +++ b/web/src/pages/loader/style.scss @@ -1,6 +1,6 @@ #loader-page { + min-height: 100dvh; width: 100%; - height: 100%; display: flex; flex-direction: column; align-content: center; diff --git a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx index 4187f48063..f73bcecd00 100644 --- a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx +++ b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx @@ -117,12 +117,14 @@ export const NetworkEditForm = () => { name: z.string().min(1, LL.form.error.required()), address: z .string() + .trim() .min(1, LL.form.error.required()) .refine((value) => { return validateIpList(value, ',', true); }, LL.form.error.addressNetmask()), endpoint: z .string() + .trim() .min(1, LL.form.error.required()) .refine( (val) => validateIpOrDomain(val, false, true), @@ -136,6 +138,7 @@ export const NetworkEditForm = () => { allowed_ips: z.string(), dns: z .string() + .trim() .optional() .refine((val) => { if (val === '' || !val) { @@ -206,7 +209,17 @@ export const NetworkEditForm = () => { address = data.address.join(','); } - return { ...defaultValues, ...omited, allowed_ips, address }; + // we changed the default and this field is conditionally disabled + const peer_disconnect_threshold = + data.peer_disconnect_threshold < 120 ? 120 : data.peer_disconnect_threshold; + + return { + ...defaultValues, + ...omited, + allowed_ips, + address, + peer_disconnect_threshold, + }; }, [defaultValues], ); @@ -295,6 +308,9 @@ export const NetworkEditForm = () => { controller={{ control, name: 'endpoint' }} label={LL.networkConfiguration.form.fields.endpoint.label()} /> + +

{LL.networkConfiguration.form.helpers.endpoint()}

+
{ z.object({ name: z .string() + .trim() .min(4, LL.form.error.minimumLength()) .max(16, LL.form.error.maximumLength()) .min(1, LL.form.error.required()), @@ -102,6 +103,7 @@ export const OpenIdClientModalForm = () => { z.object({ url: z .string() + .trim() .min( 1, LL.openidOverview.modals.openidClientModal.form.error.urlRequired(), diff --git a/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx b/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx index c297e26868..91403bc624 100644 --- a/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx +++ b/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx @@ -13,7 +13,7 @@ export const EditLocationsSettingsButton = () => { const { LL } = useI18nContext(); const { networkId } = useParams(); const navigate = useNavigate(); - const selectedNetwork = parseInt(networkId ?? ''); + const selectedNetwork = parseInt(networkId ?? '', 10); const setNetworkPageStore = useNetworkPageStore((s) => s.setState); const handleClick = () => { diff --git a/web/src/pages/overview-index/components/OverviewNetworkSelection/OverviewNetworkSelection.tsx b/web/src/pages/overview-index/components/OverviewNetworkSelection/OverviewNetworkSelection.tsx index 2fd1991201..3c4f11685d 100644 --- a/web/src/pages/overview-index/components/OverviewNetworkSelection/OverviewNetworkSelection.tsx +++ b/web/src/pages/overview-index/components/OverviewNetworkSelection/OverviewNetworkSelection.tsx @@ -33,7 +33,7 @@ export const OverviewNetworkSelection = () => { const selectionValue = useMemo(() => { if (networkId) { - const value = parseInt(networkId); + const value = parseInt(networkId, 10); if (!Number.isNaN(value) && typeof value === 'number') { return value; } diff --git a/web/src/pages/overview-index/components/hooks/useOverviewTimeSelection.ts b/web/src/pages/overview-index/components/hooks/useOverviewTimeSelection.ts index c6ff4080ea..a42cd64c93 100644 --- a/web/src/pages/overview-index/components/hooks/useOverviewTimeSelection.ts +++ b/web/src/pages/overview-index/components/hooks/useOverviewTimeSelection.ts @@ -7,7 +7,7 @@ export const useOverviewTimeSelection = () => { const fromValue = useMemo((): number => { const searchValue = searchParams.get('from'); if (searchValue) { - const parsed = parseInt(searchValue); + const parsed = parseInt(searchValue, 10); if (parsed && !Number.isNaN(parsed)) { return parsed; } diff --git a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx index d06cf879ac..906ab860d6 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx +++ b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx @@ -3,8 +3,8 @@ import './style.scss'; import classNames from 'classnames'; import dayjs from 'dayjs'; -import { motion } from 'framer-motion'; import { sumBy } from 'lodash-es'; +import { motion } from 'motion/react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { timer } from 'rxjs'; diff --git a/web/src/pages/overview/OverviewPage.tsx b/web/src/pages/overview/OverviewPage.tsx index 4a107eb901..9def60e10e 100644 --- a/web/src/pages/overview/OverviewPage.tsx +++ b/web/src/pages/overview/OverviewPage.tsx @@ -36,7 +36,7 @@ export const OverviewPage = () => { const { LL } = useI18nContext(); const { from: statsFilter } = useOverviewTimeSelection(); const { networkId } = useParams(); - const selectedNetworkId = parseInt(networkId ?? ''); + const selectedNetworkId = parseInt(networkId ?? '', 10); const location = useLocation(); const { diff --git a/web/src/pages/overview/OverviewStats/OverviewStats.tsx b/web/src/pages/overview/OverviewStats/OverviewStats.tsx index b2ff3a5264..f4ebc26d88 100644 --- a/web/src/pages/overview/OverviewStats/OverviewStats.tsx +++ b/web/src/pages/overview/OverviewStats/OverviewStats.tsx @@ -14,7 +14,7 @@ import { NetworkSpeed } from '../../../shared/defguard-ui/components/Layout/Netw import { NetworkDirection } from '../../../shared/defguard-ui/components/Layout/NetworkSpeed/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import type { WireguardNetworkStats } from '../../../shared/types'; -import { useOverviewStore } from '../hooks/store/useOverviewStore'; +import { useOverviewTimeSelection } from '../../overview-index/components/hooks/useOverviewTimeSelection'; import { NetworkUsageChart } from '../OverviewConnectedUsers/shared/components/NetworkUsageChart/NetworkUsageChart'; import { networkTrafficToChartData } from './utils'; @@ -24,7 +24,7 @@ interface Props { export const OverviewStats = forwardRef( ({ networkStats }, ref) => { - const filterValue = useOverviewStore((state) => state.statsFilter); + const { from: filterValue } = useOverviewTimeSelection(); const peakDownload = useMemo(() => { const sorted = orderBy(networkStats.transfer_series, (stats) => stats.download, [ 'desc', diff --git a/web/src/pages/overview/OverviewStatsFilterSelect/OverviewStatsFilterSelect.tsx b/web/src/pages/overview/OverviewStatsFilterSelect/OverviewStatsFilterSelect.tsx deleted file mode 100644 index 493455b749..0000000000 --- a/web/src/pages/overview/OverviewStatsFilterSelect/OverviewStatsFilterSelect.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useCallback } from 'react'; - -import { Select } from '../../../shared/defguard-ui/components/Layout/Select/Select'; -import { - type SelectOption, - type SelectSelectedValue, - SelectSizeVariant, -} from '../../../shared/defguard-ui/components/Layout/Select/types'; -import { useOverviewStore } from '../hooks/store/useOverviewStore'; - -export const OverviewStatsFilterSelect = () => { - const filterValue = useOverviewStore((state) => state.statsFilter); - const setOverviewStore = useOverviewStore((state) => state.setState); - - const renderSelected = useCallback((selected: number): SelectSelectedValue => { - return { - key: selected, - displayValue: `${selected}H`, - }; - }, []); - - return ( -