diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 034113495f..13ded1fedf 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -21,6 +21,7 @@ jobs: - self-hosted - Linux - ${{ matrix.runner }} + strategy: matrix: # cpu: [arm64, amd64, arm/v7] @@ -35,23 +36,31 @@ jobs: # - 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: @@ -63,10 +72,30 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + - 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 +104,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 +121,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 c32d9e1fbc..62bb823050 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -1,10 +1,14 @@ name: Build current image +permissions: + contents: read + id-token: write + packages: write on: push: branches: - main - dev - - 'release/**' + - "release/**" paths-ignore: - "*.md" - "LICENSE" @@ -25,3 +29,15 @@ jobs: needs: build-current uses: ./.github/workflows/e2e.yml secrets: inherit + + trigger-dev-deploy: + needs: build-current + if: ${{ github.event_name != 'pull_request' && github.ref_name == 'dev' }} + uses: ./.github/workflows/dev-deployment.yml + secrets: inherit + + trigger-staging-deploy: + needs: build-current + if: ${{ github.event_name != 'pull_request' && startsWith(github.ref_name, 'release/') }} + uses: ./.github/workflows/staging-deployment.yml + secrets: inherit diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4926315903..5382d55c24 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -63,8 +63,6 @@ jobs: ${{ 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 diff --git a/.github/workflows/lint-web.yml b/.github/workflows/lint-web.yml index 580acc98d0..12a74e2dd7 100644 --- a/.github/workflows/lint-web.yml +++ b/.github/workflows/lint-web.yml @@ -3,35 +3,39 @@ 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: submodules: "recursive" - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - 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 lint + 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 3540aa572c..ba1db86d57 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 @@ -84,6 +90,10 @@ jobs: with: submodules: recursive + - name: Setup `packer` + uses: hashicorp/setup-packer@main + id: setup + - name: Install Rust stable uses: actions-rs/toolchain@v1 with: @@ -153,6 +163,26 @@ jobs: fpm_args: "defguard-${{ github.ref_name }}-${{ matrix.target }}=/usr/bin/defguard defguard.service=/usr/lib/systemd/system/defguard.service .env-template=/etc/defguard/core.conf" fpm_opts: "--architecture ${{ matrix.arch }} --debug --output-type deb --version ${{ env.VERSION }} --package defguard-${{ env.VERSION }}-${{ matrix.target }}.deb" + - name: Run `packer init` + if: matrix.build == 'linux' && matrix.arch == 'amd64' + id: init + run: "packer init ./images/ami/core.pkr.hcl" + + - 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 eu-central-1) + for region in "${regions[@]}"; do + echo "Building AMI for region: $region" + echo "Running packer validate for $region..." + packer validate --var "package_version=${{ env.VERSION }}" --var "region=$region" ./images/ami/core.pkr.hcl + echo "Building AMI image for $region..." + packer build -color=false -on-error=abort --var "package_version=${{ env.VERSION }}" --var "region=$region" ./images/ami/core.pkr.hcl + done + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + - name: Upload DEB if: matrix.build == 'linux' uses: actions/upload-release-asset@v1.0.2 diff --git a/.sqlx/query-0d0ed874821849ae07a9f49f17600b2a4cbedb33babd5b9fc908ec17d3f882e2.json b/.sqlx/query-06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77.json similarity index 82% rename from .sqlx/query-0d0ed874821849ae07a9f49f17600b2a4cbedb33babd5b9fc908ec17d3f882e2.json rename to .sqlx/query-06bbd4a7662ea9ec62a0138efa9acb62c4bd9b646846740333d8ae3d154d1d77.json index c7a2ba03bc..dec553bccb 100644 --- a/.sqlx/query-0d0ed874821849ae07a9f49f17600b2a4cbedb33babd5b9fc908ec17d3f882e2.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": "0d0ed874821849ae07a9f49f17600b2a4cbedb33babd5b9fc908ec17d3f882e2" + "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-0c18e9d0f192e36ed65569c0ef124d6ab73bee88929ad223c46bb9b2892150f3.json b/.sqlx/query-0c18e9d0f192e36ed65569c0ef124d6ab73bee88929ad223c46bb9b2892150f3.json new file mode 100644 index 0000000000..f0693f9a64 --- /dev/null +++ b/.sqlx/query-0c18e9d0f192e36ed65569c0ef124d6ab73bee88929ad223c46bb9b2892150f3.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, user_id, location_id, \"public_ip\" \"public_ip: IpAddr\" FROM user_snat_binding WHERE location_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "location_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "public_ip: IpAddr", + "type_info": "Inet" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "0c18e9d0f192e36ed65569c0ef124d6ab73bee88929ad223c46bb9b2892150f3" +} diff --git a/.sqlx/query-f0d9d574025f8bf22bd8c2195c3d864bab119c33ee43b6b7d02bf86b16e567c0.json b/.sqlx/query-0c7b5094f1e4dc79a5782b6948aa2527807bd9643f2758c83c728d1d003843ab.json similarity index 76% rename from .sqlx/query-f0d9d574025f8bf22bd8c2195c3d864bab119c33ee43b6b7d02bf86b16e567c0.json rename to .sqlx/query-0c7b5094f1e4dc79a5782b6948aa2527807bd9643f2758c83c728d1d003843ab.json index 6448dcc386..da1e780cd7 100644 --- a/.sqlx/query-f0d9d574025f8bf22bd8c2195c3d864bab119c33ee43b6b7d02bf86b16e567c0.json +++ b/.sqlx/query-0c7b5094f1e4dc79a5782b6948aa2527807bd9643f2758c83c728d1d003843ab.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"allowed_ips\" \"allowed_ips: _\",\"connected_at\",\"mfa_enabled\",\"acl_enabled\",\"acl_default_allow\",\"keepalive_interval\",\"peer_disconnect_threshold\" FROM \"wireguard_network\"", + "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"allowed_ips\" \"allowed_ips: _\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"location_mfa_mode\" \"location_mfa_mode: _\" FROM \"wireguard_network\"", "describe": { "columns": [ { @@ -55,28 +55,39 @@ }, { "ordinal": 10, - "name": "mfa_enabled", + "name": "acl_enabled", "type_info": "Bool" }, { "ordinal": 11, - "name": "acl_enabled", + "name": "acl_default_allow", "type_info": "Bool" }, { "ordinal": 12, - "name": "acl_default_allow", - "type_info": "Bool" + "name": "keepalive_interval", + "type_info": "Int4" }, { "ordinal": 13, - "name": "keepalive_interval", + "name": "peer_disconnect_threshold", "type_info": "Int4" }, { "ordinal": 14, - "name": "peer_disconnect_threshold", - "type_info": "Int4" + "name": "location_mfa_mode: _", + "type_info": { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } } ], "parameters": { @@ -100,5 +111,5 @@ false ] }, - "hash": "f0d9d574025f8bf22bd8c2195c3d864bab119c33ee43b6b7d02bf86b16e567c0" + "hash": "0c7b5094f1e4dc79a5782b6948aa2527807bd9643f2758c83c728d1d003843ab" } diff --git a/.sqlx/query-0effde2f87ca6a7d9ce34daedb6462deb43863778ea17a47696912f412714741.json b/.sqlx/query-0effde2f87ca6a7d9ce34daedb6462deb43863778ea17a47696912f412714741.json new file mode 100644 index 0000000000..f9349763ad --- /dev/null +++ b/.sqlx/query-0effde2f87ca6a7d9ce34daedb6462deb43863778ea17a47696912f412714741.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"user_id\",\"location_id\",\"public_ip\" \"public_ip: IpAddr\" FROM \"user_snat_binding\" WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "location_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "public_ip: IpAddr", + "type_info": "Inet" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "0effde2f87ca6a7d9ce34daedb6462deb43863778ea17a47696912f412714741" +} diff --git a/.sqlx/query-0fb053b3b00a1fe78f764d2d1d90375d5674fd59fe3018af120ae2ef5fd10f48.json b/.sqlx/query-0fb053b3b00a1fe78f764d2d1d90375d5674fd59fe3018af120ae2ef5fd10f48.json new file mode 100644 index 0000000000..4d94977db3 --- /dev/null +++ b/.sqlx/query-0fb053b3b00a1fe78f764d2d1d90375d5674fd59fe3018af120ae2ef5fd10f48.json @@ -0,0 +1,117 @@ +{ + "db_name": "PostgreSQL", + "query": "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\" FROM wireguard_network WHERE id IN (SELECT wireguard_network_id FROM wireguard_network_device WHERE device_id = $1 ORDER BY id LIMIT 1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "InetArray" + }, + { + "ordinal": 3, + "name": "port", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "pubkey", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "prvkey", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "endpoint", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "dns", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "allowed_ips", + "type_info": "InetArray" + }, + { + "ordinal": 9, + "name": "connected_at", + "type_info": "Timestamp" + }, + { + "ordinal": 10, + "name": "keepalive_interval", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "peer_disconnect_threshold", + "type_info": "Int4" + }, + { + "ordinal": 12, + "name": "acl_enabled", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "acl_default_allow", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "location_mfa_mode: LocationMfaMode", + "type_info": { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "0fb053b3b00a1fe78f764d2d1d90375d5674fd59fe3018af120ae2ef5fd10f48" +} 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-64988ec3d7d38eb543dd4e755283adbce8853f50fb46c1cc6b703256afe0d7af.json b/.sqlx/query-1aea3a14458db09848bd40493a931ee33640bd508ff8a63209288e075383d11f.json similarity index 76% rename from .sqlx/query-64988ec3d7d38eb543dd4e755283adbce8853f50fb46c1cc6b703256afe0d7af.json rename to .sqlx/query-1aea3a14458db09848bd40493a931ee33640bd508ff8a63209288e075383d11f.json index ab9b1a6d11..18cee1391d 100644 --- a/.sqlx/query-64988ec3d7d38eb543dd4e755283adbce8853f50fb46c1cc6b703256afe0d7af.json +++ b/.sqlx/query-1aea3a14458db09848bd40493a931ee33640bd508ff8a63209288e075383d11f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"metadata\" FROM \"activity_log_event\" WHERE id = $1", + "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"location\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"description\",\"metadata\" FROM \"activity_log_event\" WHERE id = $1", "describe": { "columns": [ { @@ -25,16 +25,21 @@ }, { "ordinal": 4, + "name": "location", + "type_info": "Text" + }, + { + "ordinal": 5, "name": "ip", "type_info": "Inet" }, { - "ordinal": 5, + "ordinal": 6, "name": "event: _", "type_info": "Text" }, { - "ordinal": 6, + "ordinal": 7, "name": "module: _", "type_info": { "Custom": { @@ -51,12 +56,17 @@ } }, { - "ordinal": 7, + "ordinal": 8, "name": "device", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 10, "name": "metadata", "type_info": "Jsonb" } @@ -71,12 +81,14 @@ false, false, false, + true, false, false, false, false, + true, true ] }, - "hash": "64988ec3d7d38eb543dd4e755283adbce8853f50fb46c1cc6b703256afe0d7af" + "hash": "1aea3a14458db09848bd40493a931ee33640bd508ff8a63209288e075383d11f" } diff --git a/.sqlx/query-81f1d11c1b7a2299b26c25be37d209a191845dabb64ec3390c443501ff531bb3.json b/.sqlx/query-21957027aa29a30a186e87441b86eadc2d27eeb56d18f9debf50d0ba71e01e48.json similarity index 74% rename from .sqlx/query-81f1d11c1b7a2299b26c25be37d209a191845dabb64ec3390c443501ff531bb3.json rename to .sqlx/query-21957027aa29a30a186e87441b86eadc2d27eeb56d18f9debf50d0ba71e01e48.json index e79348ad65..1b397c0991 100644 --- a/.sqlx/query-81f1d11c1b7a2299b26c25be37d209a191845dabb64ec3390c443501ff531bb3.json +++ b/.sqlx/query-21957027aa29a30a186e87441b86eadc2d27eeb56d18f9debf50d0ba71e01e48.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow FROM wireguard_network WHERE id = $1", + "query": "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\" FROM wireguard_network WHERE id = $1", "describe": { "columns": [ { @@ -55,28 +55,39 @@ }, { "ordinal": 10, - "name": "mfa_enabled", - "type_info": "Bool" - }, - { - "ordinal": 11, "name": "keepalive_interval", "type_info": "Int4" }, { - "ordinal": 12, + "ordinal": 11, "name": "peer_disconnect_threshold", "type_info": "Int4" }, { - "ordinal": 13, + "ordinal": 12, "name": "acl_enabled", "type_info": "Bool" }, { - "ordinal": 14, + "ordinal": 13, "name": "acl_default_allow", "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "location_mfa_mode: LocationMfaMode", + "type_info": { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } } ], "parameters": { @@ -102,5 +113,5 @@ false ] }, - "hash": "81f1d11c1b7a2299b26c25be37d209a191845dabb64ec3390c443501ff531bb3" + "hash": "21957027aa29a30a186e87441b86eadc2d27eeb56d18f9debf50d0ba71e01e48" } 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-02b5696315d44f2febf1fe850071d88892e05437c35c60776ee5d9e3d190931a.json b/.sqlx/query-32187156f93aaff898a4445056a2a332453a9626e56a5f543c2790bff9d2109c.json similarity index 76% rename from .sqlx/query-02b5696315d44f2febf1fe850071d88892e05437c35c60776ee5d9e3d190931a.json rename to .sqlx/query-32187156f93aaff898a4445056a2a332453a9626e56a5f543c2790bff9d2109c.json index b2b9ba5ecc..37b1fe0d05 100644 --- a/.sqlx/query-02b5696315d44f2febf1fe850071d88892e05437c35c60776ee5d9e3d190931a.json +++ b/.sqlx/query-32187156f93aaff898a4445056a2a332453a9626e56a5f543c2790bff9d2109c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"allowed_ips\" \"allowed_ips: _\",\"connected_at\",\"mfa_enabled\",\"acl_enabled\",\"acl_default_allow\",\"keepalive_interval\",\"peer_disconnect_threshold\" FROM \"wireguard_network\" WHERE id = $1", + "query": "SELECT id, \"name\",\"address\" \"address: _\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"allowed_ips\" \"allowed_ips: _\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"location_mfa_mode\" \"location_mfa_mode: _\" FROM \"wireguard_network\" WHERE id = $1", "describe": { "columns": [ { @@ -55,28 +55,39 @@ }, { "ordinal": 10, - "name": "mfa_enabled", + "name": "acl_enabled", "type_info": "Bool" }, { "ordinal": 11, - "name": "acl_enabled", + "name": "acl_default_allow", "type_info": "Bool" }, { "ordinal": 12, - "name": "acl_default_allow", - "type_info": "Bool" + "name": "keepalive_interval", + "type_info": "Int4" }, { "ordinal": 13, - "name": "keepalive_interval", + "name": "peer_disconnect_threshold", "type_info": "Int4" }, { "ordinal": 14, - "name": "peer_disconnect_threshold", - "type_info": "Int4" + "name": "location_mfa_mode: _", + "type_info": { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } } ], "parameters": { @@ -102,5 +113,5 @@ false ] }, - "hash": "02b5696315d44f2febf1fe850071d88892e05437c35c60776ee5d9e3d190931a" + "hash": "32187156f93aaff898a4445056a2a332453a9626e56a5f543c2790bff9d2109c" } diff --git a/.sqlx/query-a6740807ffc09dd6ac7aa4206a9a6c8666dc3a4033a4f909658d698f4bb5cd9b.json b/.sqlx/query-32e8f8f8a19578caad21123b4a5f2cbe4336115c83f341311bec75e3a817138b.json similarity index 76% rename from .sqlx/query-a6740807ffc09dd6ac7aa4206a9a6c8666dc3a4033a4f909658d698f4bb5cd9b.json rename to .sqlx/query-32e8f8f8a19578caad21123b4a5f2cbe4336115c83f341311bec75e3a817138b.json index ec581a84e5..0083e58323 100644 --- a/.sqlx/query-a6740807ffc09dd6ac7aa4206a9a6c8666dc3a4033a4f909658d698f4bb5cd9b.json +++ b/.sqlx/query-32e8f8f8a19578caad21123b4a5f2cbe4336115c83f341311bec75e3a817138b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"metadata\" FROM \"activity_log_event\"", + "query": "SELECT id, \"timestamp\",\"user_id\",\"username\",\"location\",\"ip\",\"event\" \"event: _\",\"module\" \"module: _\",\"device\",\"description\",\"metadata\" FROM \"activity_log_event\"", "describe": { "columns": [ { @@ -25,16 +25,21 @@ }, { "ordinal": 4, + "name": "location", + "type_info": "Text" + }, + { + "ordinal": 5, "name": "ip", "type_info": "Inet" }, { - "ordinal": 5, + "ordinal": 6, "name": "event: _", "type_info": "Text" }, { - "ordinal": 6, + "ordinal": 7, "name": "module: _", "type_info": { "Custom": { @@ -51,12 +56,17 @@ } }, { - "ordinal": 7, + "ordinal": 8, "name": "device", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 10, "name": "metadata", "type_info": "Jsonb" } @@ -69,12 +79,14 @@ false, false, false, + true, false, false, false, false, + true, true ] }, - "hash": "a6740807ffc09dd6ac7aa4206a9a6c8666dc3a4033a4f909658d698f4bb5cd9b" + "hash": "32e8f8f8a19578caad21123b4a5f2cbe4336115c83f341311bec75e3a817138b" } 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-2ae013255e664b19ed582b115b46133034efd7cf22ac1562b1696687f5783a46.json b/.sqlx/query-4bb5ca9ed7718e206afe39c6a2adc10ffe1db95cec3bb2d20123025f4af46b21.json similarity index 68% rename from .sqlx/query-2ae013255e664b19ed582b115b46133034efd7cf22ac1562b1696687f5783a46.json rename to .sqlx/query-4bb5ca9ed7718e206afe39c6a2adc10ffe1db95cec3bb2d20123025f4af46b21.json index 50e31a5ba3..25fe25eb02 100644 --- a/.sqlx/query-2ae013255e664b19ed582b115b46133034efd7cf22ac1562b1696687f5783a46.json +++ b/.sqlx/query-4bb5ca9ed7718e206afe39c6a2adc10ffe1db95cec3bb2d20123025f4af46b21.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"activity_log_event\" SET \"timestamp\" = $2,\"user_id\" = $3,\"username\" = $4,\"ip\" = $5,\"event\" = $6,\"module\" = $7,\"device\" = $8,\"metadata\" = $9 WHERE id = $1", + "query": "UPDATE \"activity_log_event\" SET \"timestamp\" = $2,\"user_id\" = $3,\"username\" = $4,\"location\" = $5,\"ip\" = $6,\"event\" = $7,\"module\" = $8,\"device\" = $9,\"description\" = $10,\"metadata\" = $11 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -9,6 +9,7 @@ "Timestamp", "Int8", "Text", + "Text", "Inet", "Text", { @@ -25,10 +26,11 @@ } }, "Text", + "Text", "Jsonb" ] }, "nullable": [] }, - "hash": "2ae013255e664b19ed582b115b46133034efd7cf22ac1562b1696687f5783a46" + "hash": "4bb5ca9ed7718e206afe39c6a2adc10ffe1db95cec3bb2d20123025f4af46b21" } diff --git a/.sqlx/query-f65176f7b65f553ddcb0240903de3b8b1951a5c48de9f25b9c921e1967a972b9.json b/.sqlx/query-5350e57595e044cea6976a73910210e5106af580e45647ae620850de0b77785b.json similarity index 73% rename from .sqlx/query-f65176f7b65f553ddcb0240903de3b8b1951a5c48de9f25b9c921e1967a972b9.json rename to .sqlx/query-5350e57595e044cea6976a73910210e5106af580e45647ae620850de0b77785b.json index f06c3c2ee1..36d70341c3 100644 --- a/.sqlx/query-f65176f7b65f553ddcb0240903de3b8b1951a5c48de9f25b9c921e1967a972b9.json +++ b/.sqlx/query-5350e57595e044cea6976a73910210e5106af580e45647ae620850de0b77785b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT n.id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow FROM aclrulenetwork r JOIN wireguard_network n ON n.id = r.network_id WHERE r.rule_id = $1", + "query": "SELECT n.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\" FROM aclrulenetwork r JOIN wireguard_network n ON n.id = r.network_id WHERE r.rule_id = $1", "describe": { "columns": [ { @@ -55,28 +55,39 @@ }, { "ordinal": 10, - "name": "mfa_enabled", - "type_info": "Bool" - }, - { - "ordinal": 11, "name": "keepalive_interval", "type_info": "Int4" }, { - "ordinal": 12, + "ordinal": 11, "name": "peer_disconnect_threshold", "type_info": "Int4" }, { - "ordinal": 13, + "ordinal": 12, "name": "acl_enabled", "type_info": "Bool" }, { - "ordinal": 14, + "ordinal": 13, "name": "acl_default_allow", "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "location_mfa_mode: LocationMfaMode", + "type_info": { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } } ], "parameters": { @@ -102,5 +113,5 @@ false ] }, - "hash": "f65176f7b65f553ddcb0240903de3b8b1951a5c48de9f25b9c921e1967a972b9" + "hash": "5350e57595e044cea6976a73910210e5106af580e45647ae620850de0b77785b" } diff --git a/.sqlx/query-6175d2d008ccf7860ebe9f1b2e12d7dd30dac5aa72e015c19931c84010ebfcf5.json b/.sqlx/query-6175d2d008ccf7860ebe9f1b2e12d7dd30dac5aa72e015c19931c84010ebfcf5.json new file mode 100644 index 0000000000..c95aa77bf7 --- /dev/null +++ b/.sqlx/query-6175d2d008ccf7860ebe9f1b2e12d7dd30dac5aa72e015c19931c84010ebfcf5.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"user_snat_binding\" SET \"user_id\" = $2,\"location_id\" = $3,\"public_ip\" = $4 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Inet" + ] + }, + "nullable": [] + }, + "hash": "6175d2d008ccf7860ebe9f1b2e12d7dd30dac5aa72e015c19931c84010ebfcf5" +} 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-977a9511c6ea5d27224f43ef42b9048d069f6e6d041d6b9b9c95a3c0f93bd26c.json b/.sqlx/query-6f207a4d39d616b6cfdb10e4e4e7e2bf4a03081f33c0b4e779e5e431b092fbdc.json similarity index 74% rename from .sqlx/query-977a9511c6ea5d27224f43ef42b9048d069f6e6d041d6b9b9c95a3c0f93bd26c.json rename to .sqlx/query-6f207a4d39d616b6cfdb10e4e4e7e2bf4a03081f33c0b4e779e5e431b092fbdc.json index b011d62d0b..4b43522b73 100644 --- a/.sqlx/query-977a9511c6ea5d27224f43ef42b9048d069f6e6d041d6b9b9c95a3c0f93bd26c.json +++ b/.sqlx/query-6f207a4d39d616b6cfdb10e4e4e7e2bf4a03081f33c0b4e779e5e431b092fbdc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow FROM wireguard_network WHERE name = $1", + "query": "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\" FROM wireguard_network WHERE name = $1", "describe": { "columns": [ { @@ -55,28 +55,39 @@ }, { "ordinal": 10, - "name": "mfa_enabled", - "type_info": "Bool" - }, - { - "ordinal": 11, "name": "keepalive_interval", "type_info": "Int4" }, { - "ordinal": 12, + "ordinal": 11, "name": "peer_disconnect_threshold", "type_info": "Int4" }, { - "ordinal": 13, + "ordinal": 12, "name": "acl_enabled", "type_info": "Bool" }, { - "ordinal": 14, + "ordinal": 13, "name": "acl_default_allow", "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "location_mfa_mode: LocationMfaMode", + "type_info": { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } } ], "parameters": { @@ -102,5 +113,5 @@ false ] }, - "hash": "977a9511c6ea5d27224f43ef42b9048d069f6e6d041d6b9b9c95a3c0f93bd26c" + "hash": "6f207a4d39d616b6cfdb10e4e4e7e2bf4a03081f33c0b4e779e5e431b092fbdc" } 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-876e1659850a050155f3938231e801b381b112d54971c86beaad5fd679fbd5ac.json b/.sqlx/query-876e1659850a050155f3938231e801b381b112d54971c86beaad5fd679fbd5ac.json new file mode 100644 index 0000000000..51f0e042d1 --- /dev/null +++ b/.sqlx/query-876e1659850a050155f3938231e801b381b112d54971c86beaad5fd679fbd5ac.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, user_id, location_id, \"public_ip\" \"public_ip: IpAddr\" FROM user_snat_binding WHERE location_id = $1 AND user_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "location_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "public_ip: IpAddr", + "type_info": "Inet" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "876e1659850a050155f3938231e801b381b112d54971c86beaad5fd679fbd5ac" +} diff --git a/.sqlx/query-87868c21e47dd3b55ba1aefb690ce4ea9b463d7e71533e9c6312939a3d77b49e.json b/.sqlx/query-87868c21e47dd3b55ba1aefb690ce4ea9b463d7e71533e9c6312939a3d77b49e.json new file mode 100644 index 0000000000..0db9a2aa7c --- /dev/null +++ b/.sqlx/query-87868c21e47dd3b55ba1aefb690ce4ea9b463d7e71533e9c6312939a3d77b49e.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"user_id\",\"location_id\",\"public_ip\" \"public_ip: IpAddr\" FROM \"user_snat_binding\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "location_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "public_ip: IpAddr", + "type_info": "Inet" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "87868c21e47dd3b55ba1aefb690ce4ea9b463d7e71533e9c6312939a3d77b49e" +} 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-86f65d13c3f0221b4de39a7042b578149eca53934959119f61635b276b8b0c9e.json b/.sqlx/query-89ec538b614f4ea6440ce15ec58044149463f9a39f673b1f83587980c527c575.json similarity index 73% rename from .sqlx/query-86f65d13c3f0221b4de39a7042b578149eca53934959119f61635b276b8b0c9e.json rename to .sqlx/query-89ec538b614f4ea6440ce15ec58044149463f9a39f673b1f83587980c527c575.json index 3583aa6f84..55255fc63f 100644 --- a/.sqlx/query-86f65d13c3f0221b4de39a7042b578149eca53934959119f61635b276b8b0c9e.json +++ b/.sqlx/query-89ec538b614f4ea6440ce15ec58044149463f9a39f673b1f83587980c527c575.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow FROM wireguard_network WHERE mfa_enabled = true", + "query": "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\" FROM wireguard_network WHERE location_mfa_mode = 'external'::location_mfa_mode", "describe": { "columns": [ { @@ -55,28 +55,39 @@ }, { "ordinal": 10, - "name": "mfa_enabled", - "type_info": "Bool" - }, - { - "ordinal": 11, "name": "keepalive_interval", "type_info": "Int4" }, { - "ordinal": 12, + "ordinal": 11, "name": "peer_disconnect_threshold", "type_info": "Int4" }, { - "ordinal": 13, + "ordinal": 12, "name": "acl_enabled", "type_info": "Bool" }, { - "ordinal": 14, + "ordinal": 13, "name": "acl_default_allow", "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "location_mfa_mode: LocationMfaMode", + "type_info": { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } } ], "parameters": { @@ -100,5 +111,5 @@ false ] }, - "hash": "86f65d13c3f0221b4de39a7042b578149eca53934959119f61635b276b8b0c9e" + "hash": "89ec538b614f4ea6440ce15ec58044149463f9a39f673b1f83587980c527c575" } diff --git a/.sqlx/query-8d8416a6cc1f0bae02e126ca398e87000f305499668455d7e4d949f7d0a7be9a.json b/.sqlx/query-8d8416a6cc1f0bae02e126ca398e87000f305499668455d7e4d949f7d0a7be9a.json new file mode 100644 index 0000000000..01f2bb1f99 --- /dev/null +++ b/.sqlx/query-8d8416a6cc1f0bae02e126ca398e87000f305499668455d7e4d949f7d0a7be9a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM \"user_snat_binding\" WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "8d8416a6cc1f0bae02e126ca398e87000f305499668455d7e4d949f7d0a7be9a" +} diff --git a/.sqlx/query-87447fdf74676697cd2a75b790d403af0301b5e6a9f4a69448adc26c767b8e24.json b/.sqlx/query-909ee355dfb5e56659b3d3b3ea5c7be8c83337a49ccc383c8b2e85e9862f0047.json similarity index 72% rename from .sqlx/query-87447fdf74676697cd2a75b790d403af0301b5e6a9f4a69448adc26c767b8e24.json rename to .sqlx/query-909ee355dfb5e56659b3d3b3ea5c7be8c83337a49ccc383c8b2e85e9862f0047.json index 04cd520d81..343fb78d3d 100644 --- a/.sqlx/query-87447fdf74676697cd2a75b790d403af0301b5e6a9f4a69448adc26c767b8e24.json +++ b/.sqlx/query-909ee355dfb5e56659b3d3b3ea5c7be8c83337a49ccc383c8b2e85e9862f0047.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"activity_log_event\" (\"timestamp\",\"user_id\",\"username\",\"ip\",\"event\",\"module\",\"device\",\"metadata\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id", + "query": "INSERT INTO \"activity_log_event\" (\"timestamp\",\"user_id\",\"username\",\"location\",\"ip\",\"event\",\"module\",\"device\",\"description\",\"metadata\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id", "describe": { "columns": [ { @@ -14,6 +14,7 @@ "Timestamp", "Int8", "Text", + "Text", "Inet", "Text", { @@ -30,6 +31,7 @@ } }, "Text", + "Text", "Jsonb" ] }, @@ -37,5 +39,5 @@ false ] }, - "hash": "87447fdf74676697cd2a75b790d403af0301b5e6a9f4a69448adc26c767b8e24" + "hash": "909ee355dfb5e56659b3d3b3ea5c7be8c83337a49ccc383c8b2e85e9862f0047" } diff --git a/.sqlx/query-ce3e4369b0e449a7fefd45dd52c86849dd6d17db19db0fbeb833e68eee17d5fe.json b/.sqlx/query-966dd7a3677babebc8e34b6f502e7e1aea7304e054c1241406aa62993d66117a.json similarity index 51% rename from .sqlx/query-ce3e4369b0e449a7fefd45dd52c86849dd6d17db19db0fbeb833e68eee17d5fe.json rename to .sqlx/query-966dd7a3677babebc8e34b6f502e7e1aea7304e054c1241406aa62993d66117a.json index 8b46245120..070daed9c5 100644 --- a/.sqlx/query-ce3e4369b0e449a7fefd45dd52c86849dd6d17db19db0fbeb833e68eee17d5fe.json +++ b/.sqlx/query-966dd7a3677babebc8e34b6f502e7e1aea7304e054c1241406aa62993d66117a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"wireguard_network\" (\"name\",\"address\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"allowed_ips\",\"connected_at\",\"mfa_enabled\",\"acl_enabled\",\"acl_default_allow\",\"keepalive_interval\",\"peer_disconnect_threshold\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id", + "query": "INSERT INTO \"wireguard_network\" (\"name\",\"address\",\"port\",\"pubkey\",\"prvkey\",\"endpoint\",\"dns\",\"allowed_ips\",\"connected_at\",\"acl_enabled\",\"acl_default_allow\",\"keepalive_interval\",\"peer_disconnect_threshold\",\"location_mfa_mode\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id", "describe": { "columns": [ { @@ -22,14 +22,25 @@ "Timestamp", "Bool", "Bool", - "Bool", "Int4", - "Int4" + "Int4", + { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } ] }, "nullable": [ false ] }, - "hash": "ce3e4369b0e449a7fefd45dd52c86849dd6d17db19db0fbeb833e68eee17d5fe" + "hash": "966dd7a3677babebc8e34b6f502e7e1aea7304e054c1241406aa62993d66117a" } 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-f948e46b9f5b331e4101c0983b54eebe29f9dd14e6b875b530171b3607ee3d94.json b/.sqlx/query-acb58694a7dc5ac4268c88e2d37a50697d20952973b676d574b01d0836f1aff0.json similarity index 52% rename from .sqlx/query-f948e46b9f5b331e4101c0983b54eebe29f9dd14e6b875b530171b3607ee3d94.json rename to .sqlx/query-acb58694a7dc5ac4268c88e2d37a50697d20952973b676d574b01d0836f1aff0.json index 5478f802b6..933ffa8b91 100644 --- a/.sqlx/query-f948e46b9f5b331e4101c0983b54eebe29f9dd14e6b875b530171b3607ee3d94.json +++ b/.sqlx/query-acb58694a7dc5ac4268c88e2d37a50697d20952973b676d574b01d0836f1aff0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"wireguard_network\" SET \"name\" = $2,\"address\" = $3,\"port\" = $4,\"pubkey\" = $5,\"prvkey\" = $6,\"endpoint\" = $7,\"dns\" = $8,\"allowed_ips\" = $9,\"connected_at\" = $10,\"mfa_enabled\" = $11,\"acl_enabled\" = $12,\"acl_default_allow\" = $13,\"keepalive_interval\" = $14,\"peer_disconnect_threshold\" = $15 WHERE id = $1", + "query": "UPDATE \"wireguard_network\" SET \"name\" = $2,\"address\" = $3,\"port\" = $4,\"pubkey\" = $5,\"prvkey\" = $6,\"endpoint\" = $7,\"dns\" = $8,\"allowed_ips\" = $9,\"connected_at\" = $10,\"acl_enabled\" = $11,\"acl_default_allow\" = $12,\"keepalive_interval\" = $13,\"peer_disconnect_threshold\" = $14,\"location_mfa_mode\" = $15 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -17,12 +17,23 @@ "Timestamp", "Bool", "Bool", - "Bool", "Int4", - "Int4" + "Int4", + { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } ] }, "nullable": [] }, - "hash": "f948e46b9f5b331e4101c0983b54eebe29f9dd14e6b875b530171b3607ee3d94" + "hash": "acb58694a7dc5ac4268c88e2d37a50697d20952973b676d574b01d0836f1aff0" } 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-cda76abbdfed425c3b06c5b64115529ffe33c7290a63adfbaaa416efeffefac3.json b/.sqlx/query-cda76abbdfed425c3b06c5b64115529ffe33c7290a63adfbaaa416efeffefac3.json new file mode 100644 index 0000000000..b606660ad7 --- /dev/null +++ b/.sqlx/query-cda76abbdfed425c3b06c5b64115529ffe33c7290a63adfbaaa416efeffefac3.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"user_snat_binding\" (\"user_id\",\"location_id\",\"public_ip\") VALUES ($1,$2,$3) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Inet" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cda76abbdfed425c3b06c5b64115529ffe33c7290a63adfbaaa416efeffefac3" +} 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-89c395e2709800a8cbd56bb9aca258b435e1b0c8365af30496523458f09e2390.json b/.sqlx/query-d6298964d89e9fdb087f7860a38f2f325d5ec81e6e0ea10660f74084718ac48e.json similarity index 72% rename from .sqlx/query-89c395e2709800a8cbd56bb9aca258b435e1b0c8365af30496523458f09e2390.json rename to .sqlx/query-d6298964d89e9fdb087f7860a38f2f325d5ec81e6e0ea10660f74084718ac48e.json index d264ca5233..cee5190d9a 100644 --- a/.sqlx/query-89c395e2709800a8cbd56bb9aca258b435e1b0c8365af30496523458f09e2390.json +++ b/.sqlx/query-d6298964d89e9fdb087f7860a38f2f325d5ec81e6e0ea10660f74084718ac48e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow FROM wireguard_network WHERE id IN (SELECT wireguard_network_id FROM wireguard_network_device WHERE device_id = $1 ORDER BY id LIMIT 1)", + "query": "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\" FROM wireguard_network WHERE location_mfa_mode != 'disabled'::location_mfa_mode", "describe": { "columns": [ { @@ -55,34 +55,43 @@ }, { "ordinal": 10, - "name": "mfa_enabled", - "type_info": "Bool" - }, - { - "ordinal": 11, "name": "keepalive_interval", "type_info": "Int4" }, { - "ordinal": 12, + "ordinal": 11, "name": "peer_disconnect_threshold", "type_info": "Int4" }, { - "ordinal": 13, + "ordinal": 12, "name": "acl_enabled", "type_info": "Bool" }, { - "ordinal": 14, + "ordinal": 13, "name": "acl_default_allow", "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "location_mfa_mode: LocationMfaMode", + "type_info": { + "Custom": { + "name": "location_mfa_mode", + "kind": { + "Enum": [ + "disabled", + "internal", + "external" + ] + } + } + } } ], "parameters": { - "Left": [ - "Int8" - ] + "Left": [] }, "nullable": [ false, @@ -102,5 +111,5 @@ false ] }, - "hash": "89c395e2709800a8cbd56bb9aca258b435e1b0c8365af30496523458f09e2390" + "hash": "d6298964d89e9fdb087f7860a38f2f325d5ec81e6e0ea10660f74084718ac48e" } 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 af1f6d0e80..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", ] @@ -205,7 +200,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", "synstructure", ] @@ -217,7 +212,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -239,18 +234,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[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", - "syn 2.0.104", + "syn", ] [[package]] @@ -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", ] @@ -466,20 +414,35 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "base64urlsafedata" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f0ad38ce7fbed55985ad5b2197f05cff8324ee6eb6638304e78f0108fae56c" +checksum = "e5913e643e4dfb43d5908e9e6f1386f8e0dfde086ecef124a6450c6195d89160" dependencies = [ "base64 0.21.7", - "paste", + "pastey", "serde", ] [[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" @@ -554,9 +529,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.18.1" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "byteorder" @@ -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.27" +version = "1.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +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.40" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -718,9 +674,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -730,14 +686,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.40" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -763,23 +719,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "compact_jwt" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bbab6445446e8d0b07468a01d0bfdae15879de5c440c5e47ae4ae0e18a1fba" -dependencies = [ - "base64 0.21.7", - "base64urlsafedata", - "hex", - "openssl", - "serde", - "serde_json", - "tracing", - "url", - "uuid", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -815,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" @@ -918,9 +851,9 @@ checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -961,9 +894,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" @@ -1021,7 +954,24 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "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]] @@ -1045,7 +995,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn", ] [[package]] @@ -1056,7 +1006,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -1076,27 +1026,27 @@ dependencies = [ [[package]] name = "defguard" -version = "1.4.1" +version = "0.0.0" dependencies = [ "anyhow", "bytes", "defguard_core", "defguard_event_logger", "defguard_event_router", + "defguard_version", "dotenvy", "secrecy", "tokio", "tracing", - "tracing-subscriber", ] [[package]] name = "defguard_core" -version = "1.4.1" +version = "1.5.0" dependencies = [ "anyhow", "argon2", - "axum 0.8.4", + "axum", "axum-client-ip", "axum-extra", "base32", @@ -1105,9 +1055,11 @@ dependencies = [ "chrono", "claims", "clap", + "defguard_version", "defguard_web_ui", - "dotenvy", + "ed25519-dalek", "humantime", + "hyper-util", "ipnetwork", "jsonwebkey", "jsonwebtoken", @@ -1121,10 +1073,8 @@ dependencies = [ "paste", "pgp", "prost", - "prost-build", "pulldown-cmark", "rand 0.8.5", - "rand_core 0.6.4", "regex", "reqwest", "rsa", @@ -1144,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", @@ -1166,7 +1117,6 @@ dependencies = [ "webauthn-rs", "webauthn-rs-proto", "x25519-dalek", - "zip 2.4.2", ] [[package]] @@ -1176,10 +1126,9 @@ dependencies = [ "bytes", "chrono", "defguard_core", - "ipnetwork", "serde_json", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tracing", ] @@ -1190,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" @@ -1237,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", @@ -1247,13 +1206,13 @@ 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", - "syn 2.0.104", + "syn", ] [[package]] @@ -1274,7 +1233,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -1284,7 +1243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.104", + "syn", ] [[package]] @@ -1297,27 +1256,27 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.104", + "syn", ] [[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", - "syn 2.0.104", + "syn", "unicode-xid", ] @@ -1356,7 +1315,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -1401,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" @@ -1444,12 +1403,13 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -1472,6 +1432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", + "base64ct", "crypto-bigint", "digest", "ff", @@ -1482,7 +1443,10 @@ dependencies = [ "pkcs8", "rand_core 0.6.4", "sec1", + "serde_json", + "serdect 0.2.0", "subtle", + "tap", "zeroize", ] @@ -1519,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]] @@ -1540,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", @@ -1561,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", ] @@ -1571,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" @@ -1628,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", ] @@ -1645,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" @@ -1712,7 +1689,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -1758,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", ] @@ -1788,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", ] @@ -1814,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", @@ -1830,8 +1807,8 @@ dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] @@ -1840,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", ] @@ -1858,9 +1835,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -1868,7 +1845,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.9.0", + "indexmap 2.11.1", "slab", "tokio", "tokio-util", @@ -1899,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", @@ -1914,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]] @@ -1988,7 +1965,7 @@ checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" dependencies = [ "cfg-if", "libc", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -2060,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", @@ -2074,6 +2052,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -2127,9 +2106,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", @@ -2278,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", @@ -2307,7 +2286,7 @@ dependencies = [ "globset", "log", "memchr", - "regex-automata 0.4.9", + "regex-automata", "same-file", "walkdir", "winapi-util", @@ -2326,12 +2305,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "serde", ] @@ -2344,6 +2323,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2375,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" @@ -2407,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", @@ -2417,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", @@ -2525,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", @@ -2553,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" @@ -2575,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" @@ -2587,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", ] @@ -2608,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" @@ -2620,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" @@ -2636,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" @@ -2646,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]] @@ -2682,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" @@ -2766,7 +2734,7 @@ name = "model_derive" version = "0.0.0" dependencies = [ "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -2819,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]] @@ -2863,13 +2830,13 @@ checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-derive" -version = "0.3.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn", ] [[package]] @@ -2921,7 +2888,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -3003,9 +2970,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openidconnect" -version = "4.0.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dd50d4a5e7730e754f94d977efe61f611aadd3131f6a2b464f6e3a4167e8ef7" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" dependencies = [ "base64 0.21.7", "chrono", @@ -3038,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", @@ -3055,7 +3022,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -3096,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" @@ -3206,14 +3179,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] -name = "pbkdf2" -version = "0.12.2" +name = "pastey" +version = "0.1.1" 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", ] @@ -3271,7 +3240,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -3291,26 +3260,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.9.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", @@ -3415,7 +3386,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -3458,12 +3429,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] -name = "polyval" -version = "0.6.2" +name = "plist" +version = "1.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ - "cfg-if", + "base64 0.22.1", + "indexmap 2.11.1", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", "cpufeatures", "opaque-debug", "universal-hash", @@ -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,12 +3479,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.104", + "syn", ] [[package]] @@ -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,29 +3539,31 @@ dependencies = [ "prettyplease", "prost", "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", "regex", - "syn 2.0.104", + "syn", "tempfile", ] [[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", "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[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", @@ -3640,7 +3644,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tracing", "web-time", @@ -3648,20 +3652,20 @@ 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", "lru-slab", - "rand 0.9.1", + "rand 0.9.2", "ring", "rustc-hash", "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", "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" @@ -3715,9 +3725,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -3763,11 +3773,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", ] [[package]] @@ -3787,58 +3797,43 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[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", -] - -[[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", + "regex-automata", + "regex-syntax", ] [[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.20" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +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", @@ -3958,7 +3953,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.104", + "syn", "walkdir", ] @@ -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.7" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] name = "rustls" -version = "0.23.28" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +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]] @@ -4075,9 +4061,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ "ring", "rustls-pki-types", @@ -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]] @@ -4126,6 +4112,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4142,6 +4140,7 @@ dependencies = [ "der", "generic-array", "pkcs8", + "serdect 0.2.0", "subtle", "zeroize", ] @@ -4162,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", @@ -4171,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", @@ -4184,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", @@ -4197,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" @@ -4244,7 +4246,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -4254,7 +4256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" dependencies = [ "form_urlencoded", - "indexmap 2.9.0", + "indexmap 2.11.1", "itoa", "ryu", "serde", @@ -4262,9 +4264,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -4293,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]] @@ -4316,16 +4318,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.13.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.9.0", - "schemars", + "indexmap 2.11.1", + "schemars 0.9.0", + "schemars 1.0.4", "serde", "serde_derive", "serde_json", @@ -4335,14 +4338,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.13.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -4351,13 +4354,33 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.9.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" @@ -4464,7 +4487,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", ] @@ -4499,14 +4522,35 @@ dependencies = [ "serde", ] +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "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]] name = "socket2" -version = "0.5.10" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4558,9 +4602,9 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "hashlink", - "indexmap 2.9.0", + "indexmap 2.11.1", "ipnetwork", "log", "memchr", @@ -4571,7 +4615,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tokio-stream", "tracing", @@ -4589,7 +4633,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.104", + "syn", ] [[package]] @@ -4612,7 +4656,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.104", + "syn", "tokio", "url", ] @@ -4625,7 +4669,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags 2.9.4", "byteorder", "bytes", "chrono", @@ -4655,7 +4699,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", "uuid", "whoami", @@ -4669,7 +4713,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags 2.9.4", "byteorder", "chrono", "crc", @@ -4695,7 +4739,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", "uuid", "whoami", @@ -4721,7 +4765,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.12", + "thiserror 2.0.16", "tracing", "url", "uuid", @@ -4806,44 +4850,43 @@ 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", - "syn 2.0.104", + "syn", ] [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn 2.0.104", + "syn", ] [[package]] @@ -4854,20 +4897,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[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", @@ -4891,7 +4923,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -4900,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", ] @@ -4915,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]] @@ -4961,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]] @@ -4976,18 +5014,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[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", - "syn 2.0.104", + "syn", ] [[package]] @@ -5001,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", @@ -5018,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", @@ -5053,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", ] @@ -5068,19 +5105,21 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", + "slab", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5091,7 +5130,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -5128,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", @@ -5151,20 +5190,19 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.9.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", @@ -5177,14 +5215,13 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", "rustls-native-certs", - "rustls-pemfile", "socket2", + "sync_wrapper", "tokio", "tokio-rustls", "tokio-stream", - "tower 0.4.13", + "tower", "tower-layer", "tower-service", "tracing", @@ -5192,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 2.0.104", + "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]] @@ -5229,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" @@ -5257,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", @@ -5271,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", @@ -5287,7 +5332,7 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", "tracing", @@ -5325,7 +5370,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -5351,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", @@ -5375,7 +5420,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -5489,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" @@ -5544,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", @@ -5572,7 +5617,7 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.11.1", "serde", "serde_json", "utoipa-gen", @@ -5587,7 +5632,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.104", + "syn", "uuid", ] @@ -5597,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", @@ -5607,7 +5652,7 @@ dependencies = [ "url", "utoipa", "utoipa-swagger-ui-vendored", - "zip 3.0.0", + "zip", ] [[package]] @@ -5618,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", @@ -5712,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 = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.0+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -5727,35 +5781,36 @@ 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", "proc-macro2", "quote", - "syn 2.0.104", + "syn", "wasm-bindgen-shared", ] [[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", @@ -5766,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", @@ -5776,22 +5831,22 @@ 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", - "syn 2.0.104", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[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", ] @@ -5811,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", @@ -5831,12 +5886,13 @@ dependencies = [ [[package]] name = "webauthn-attestation-ca" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29e77e8859ecb93b00e4a8e56ae45f8a8dd69b1539e3d32cf4cce1db9a3a0b99" +checksum = "384e43534efe4e8f56c4eb1615a27e24d2ff29281385c843cf9f16ac1077dbdc" dependencies = [ "base64urlsafedata", "openssl", + "openssl-sys", "serde", "tracing", "uuid", @@ -5844,9 +5900,9 @@ dependencies = [ [[package]] name = "webauthn-authenticator-rs" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2f8b61965979d9dd561dc8288a89e01ecf224179b40d5d496141225b540b4" +checksum = "720d11d7d7408e6c7cf65ab4d79b1f96c2a531df4e469e12656d6b814bdcd1b1" dependencies = [ "async-stream", "async-trait", @@ -5859,10 +5915,12 @@ dependencies = [ "num-derive", "num-traits", "openssl", + "openssl-sys", "serde", "serde_bytes", "serde_cbor_2", "serde_json", + "thiserror 1.0.69", "tokio", "tokio-stream", "tracing", @@ -5875,9 +5933,9 @@ dependencies = [ [[package]] name = "webauthn-rs" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b44347ee0d66f222043663a6aaf5ec78022b9b11c3a9ed488c21f2bd5680856" +checksum = "ed1f861a94557baeb0cf711e3e55d623c46b68f4aab7aa932562f785b8b5f1ab" dependencies = [ "base64urlsafedata", "serde", @@ -5889,17 +5947,17 @@ dependencies = [ [[package]] name = "webauthn-rs-core" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef48f07ed8f3dfe304d6c48e85317feba0439675f31a13063b2936c9b4eaf0d" +checksum = "269c210cd5f183aaca860bb5733187d1dd110ebed54640f8fc1aca31a04aa4dc" dependencies = [ "base64 0.21.7", "base64urlsafedata", - "compact_jwt", "der-parser", "hex", "nom 7.1.3", "openssl", + "openssl-sys", "rand 0.8.5", "rand_chacha 0.3.1", "serde", @@ -5916,9 +5974,9 @@ dependencies = [ [[package]] name = "webauthn-rs-proto" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14e1367f70e7dc7b83afc971ce8a54d578f4fdf488ea093021180e073744a69f" +checksum = "144dbee9abb4bfad78fd283a2613f0312a0ed5955051b7864cfc98679112ae60" dependencies = [ "base64 0.21.7", "base64urlsafedata", @@ -5929,54 +5987,32 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" dependencies = [ "rustls-pki-types", ] [[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" @@ -5985,7 +6021,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -5998,7 +6034,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -6009,7 +6045,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -6018,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.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -6035,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]] @@ -6044,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]] @@ -6080,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]] @@ -6116,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", @@ -6270,21 +6322,18 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +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" @@ -6292,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" @@ -6321,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" @@ -6359,28 +6408,28 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", "synstructure", ] [[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", - "syn 2.0.104", + "syn", ] [[package]] @@ -6400,7 +6449,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", "synstructure", ] @@ -6421,7 +6470,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn", ] [[package]] @@ -6437,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", @@ -6454,37 +6503,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", -] - -[[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.9.0", - "lzma-rs", - "memchr", - "pbkdf2", - "sha1", - "thiserror 2.0.12", - "time", - "xz2", - "zeroize", - "zopfli", - "zstd", + "syn", ] [[package]] @@ -6496,16 +6515,16 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap 2.9.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" @@ -6518,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 472b998787..d6405991da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -edition = "2021" +edition = "2024" license-file = "LICENSE.md" homepage = "https://defguard.net/" repository = "https://github.com/DefGuard/defguard" @@ -11,9 +11,10 @@ resolver = "2" [workspace.dependencies] # internal crates -defguard_core = { path = "./crates/defguard_core", version = "1.3.1" } +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,28 +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" -rust-ini = "0.21" +# 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" } @@ -72,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 @@ -88,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" @@ -107,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 4fbdef8fef..b838bc5f2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ -FROM node:23-alpine AS web +FROM 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 rust:1 AS chef WORKDIR /build @@ -21,6 +21,7 @@ FROM chef AS planner COPY Cargo.toml Cargo.lock ./ COPY crates crates COPY proto proto +COPY migrations migrations RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder @@ -38,13 +39,14 @@ COPY .git .git COPY .sqlx .sqlx COPY crates crates COPY proto proto +COPY migrations migrations RUN cargo install --locked --bin defguard --path ./crates/defguard --root /build # run -FROM debian:bookworm-slim +FROM 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 89b0f7ac60..99e5e6a309 100644 --- a/README.md +++ b/README.md @@ -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 c7cc44470c..e8d31accc0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,8 @@ These versions are currently being supported with security updates: | 1.1.x | :x: | | 1.2.x | :x: | | 1.3.x | :x: | -| 1.4.x | :white_check_mark: | +| 1.4.x | :x: | +| 1.5.x | :white_check_mark: | ## Reporting a Vulnerability diff --git a/crates/defguard/Cargo.toml b/crates/defguard/Cargo.toml index 90c24f974c..a9837db037 100644 --- a/crates/defguard/Cargo.toml +++ b/crates/defguard/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "defguard" -version = "1.4.1" +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 c1a450d7af..84a176a73d 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -1,36 +1,40 @@ use std::{ fs::read_to_string, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, RwLock}, }; use bytes::Bytes; use defguard_core::{ + SERVER_CONFIG, VERSION, auth::failed_login::FailedLoginMap, config::{Command, DefGuardConfig}, db::{ - init_db, models::settings::initialize_current_settings, AppEvent, GatewayEvent, Settings, - User, + AppEvent, GatewayEvent, Settings, User, init_db, + models::settings::initialize_current_settings, }, enterprise::{ activity_log_stream::activity_log_stream_manager::run_activity_log_stream_manager, - license::{run_periodic_license_check, set_cached_license, License}, + license::{License, run_periodic_license_check, set_cached_license}, limits::update_counts, }, events::{ApiEvent, BidiStreamEvent, GrpcEvent, InternalEvent}, - grpc::{run_grpc_bidi_stream, run_grpc_server, GatewayMap, WorkerState}, + grpc::{ + WorkerState, + gateway::{client_state::ClientMap, map::GatewayMap}, + run_grpc_bidi_stream, run_grpc_server, + }, init_dev_env, init_vpn_location, - mail::{run_mail_handler, Mail}, + 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, - SERVER_CONFIG, VERSION, }; use defguard_event_logger::{message::EventLoggerMessage, run_event_logger}; -use defguard_event_router::{run_event_router, RouterReceiverSet}; +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 185728ed18..acd2a5d78c 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "defguard_core" -version = "1.4.1" +version = "1.5.0" edition.workspace = true license-file.workspace = true homepage.workspace = true @@ -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 1111d1c912..a4c2030a96 100644 --- a/crates/defguard_core/build.rs +++ b/crates/defguard_core/build.rs @@ -5,32 +5,31 @@ 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", - ], - )?; - println!("cargo:rerun-if-changed=migrations"); - println!("cargo:rerun-if-changed=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"); Ok(()) } 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 2e6b15a38e..b0fd990e20 100644 --- a/crates/defguard_core/src/auth/mod.rs +++ b/crates/defguard_core/src/auth/mod.rs @@ -11,20 +11,20 @@ use axum::{ }; use axum_client_ip::InsecureClientIp; use axum_extra::{ - extract::cookie::CookieJar, - headers::{authorization::Bearer, Authorization}, TypedHeader, + extract::cookie::CookieJar, + headers::{Authorization, authorization::Bearer}, }; use jsonwebtoken::{ - decode, encode, errors::Error as JWTError, DecodingKey, EncodingKey, Header, Validation, + DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::Error as JWTError, }; use serde::{Deserialize, Serialize}; use crate::{ appstate::AppState, db::{ - models::group::Permission, Group, Id, OAuth2AuthorizedApp, OAuth2Token, Session, - SessionState, User, + Group, Id, OAuth2AuthorizedApp, OAuth2Token, Session, SessionState, User, + 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 f5bc6ef94b..e3cf365da5 100644 --- a/crates/defguard_core/src/config.rs +++ b/crates/defguard_core/src/config.rs @@ -1,13 +1,15 @@ +use std::net::IpAddr; + use clap::{Args, Parser, Subcommand}; use humantime::Duration; use ipnetwork::IpNetwork; -use openidconnect::{core::CoreRsaPrivateSigningKey, JsonWebKeyId}; +use openidconnect::{JsonWebKeyId, core::CoreRsaPrivateSigningKey}; use reqwest::Url; use rsa::{ + RsaPrivateKey, pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}, pkcs8::{DecodePrivateKey, LineEnding}, traits::PublicKeyParts, - RsaPrivateKey, }; use secrecy::{ExposeSecret, SecretString}; @@ -160,6 +162,12 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_CHECK_RENEWAL_WINDOW", default_value = "1h")] #[serde(skip_serializing)] pub check_period_renewal_window: Duration, + + #[arg(long, env = "DEFGUARD_HTTP_BIND_ADDRESS")] + pub http_bind_address: Option, + + #[arg(long, env = "DEFGUARD_GRPC_BIND_ADDRESS")] + pub grpc_bind_address: Option, } #[derive(Clone, Debug, Subcommand)] @@ -225,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( @@ -303,8 +311,10 @@ mod tests { #[test] fn test_generate_rp_id() { - env::remove_var("DEFGUARD_WEBAUTHN_RP_ID"); - env::set_var("DEFGUARD_URL", "https://defguard.example.com"); + unsafe { + env::remove_var("DEFGUARD_WEBAUTHN_RP_ID"); + env::set_var("DEFGUARD_URL", "https://defguard.example.com"); + } let config = DefGuardConfig::new(); @@ -313,7 +323,9 @@ mod tests { Some("defguard.example.com".to_string()) ); - env::set_var("DEFGUARD_WEBAUTHN_RP_ID", "example.com"); + unsafe { + env::set_var("DEFGUARD_WEBAUTHN_RP_ID", "example.com"); + } let config = DefGuardConfig::new(); @@ -322,8 +334,10 @@ mod tests { #[test] fn test_generate_cookie_domain() { - env::remove_var("DEFGUARD_COOKIE_DOMAIN"); - env::set_var("DEFGUARD_URL", "https://defguard.example.com"); + unsafe { + env::remove_var("DEFGUARD_COOKIE_DOMAIN"); + env::set_var("DEFGUARD_URL", "https://defguard.example.com"); + } let config = DefGuardConfig::new(); @@ -332,7 +346,9 @@ mod tests { Some("defguard.example.com".to_string()) ); - env::set_var("DEFGUARD_COOKIE_DOMAIN", "example.com"); + unsafe { + env::set_var("DEFGUARD_COOKIE_DOMAIN", "example.com"); + } let config = DefGuardConfig::new(); @@ -341,14 +357,18 @@ mod tests { #[test] fn test_callback_url() { - env::set_var("DEFGUARD_URL", "https://defguard.example.com"); + unsafe { + env::set_var("DEFGUARD_URL", "https://defguard.example.com"); + } let config = DefGuardConfig::new(); assert_eq!( config.callback_url().as_str(), "https://defguard.example.com/auth/callback" ); - env::set_var("DEFGUARD_URL", "https://defguard.example.com:8443/path"); + unsafe { + env::set_var("DEFGUARD_URL", "https://defguard.example.com:8443/path"); + } let config = DefGuardConfig::new(); assert_eq!( config.callback_url().as_str(), diff --git a/crates/defguard_core/src/db/mod.rs b/crates/defguard_core/src/db/mod.rs index 805630007f..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; -#[cfg(test)] -use sqlx::postgres::PgPoolOptions; -use sqlx::postgres::{PgConnectOptions, PgPool}; +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; @@ -21,7 +21,7 @@ pub async fn init_db(host: &str, port: u16, name: &str, user: &str, password: &s let pool = PgPool::connect_with(opts) .await .expect("Database connection failed"); - sqlx::migrate!() + MIGRATOR .run(&pool) .await .expect("Cannot run database migrations."); @@ -29,6 +29,7 @@ pub async fn init_db(host: &str, port: u16, name: &str, user: &str, password: &s } pub use models::{ + MFAInfo, UserDetails, UserInfo, device::{AddDevice, Device}, group::Group, oauth2authorizedapp::OAuth2AuthorizedApp, @@ -40,12 +41,15 @@ pub use models::{ webhook::{AppEvent, HWKeyUserData, WebHook}, wireguard::{GatewayEvent, WireguardNetwork}, yubikey::YubiKey, - MFAInfo, UserDetails, UserInfo, }; -#[cfg(test)] // Helper function to instantiate pool manually as a workaround for issues with `sqlx::test` macro // reference: https://github.com/launchbadge/sqlx/issues/2567#issuecomment-2009849261 pub async fn setup_pool(options: PgConnectOptions) -> PgPool { - PgPoolOptions::new().connect_with(options).await.unwrap() + let pool = PgPoolOptions::new().connect_with(options).await.unwrap(); + MIGRATOR + .run(&pool) + .await + .expect("Cannot run database migrations."); + pool } diff --git a/crates/defguard_core/src/db/models/activity_log/metadata.rs b/crates/defguard_core/src/db/models/activity_log/metadata.rs index 3b4807eedf..c1c1032cce 100644 --- a/crates/defguard_core/src/db/models/activity_log/metadata.rs +++ b/crates/defguard_core/src/db/models/activity_log/metadata.rs @@ -1,4 +1,30 @@ -use crate::db::{Device, Id, MFAMethod, WireguardNetwork}; +use chrono::NaiveDateTime; + +use crate::{ + db::{ + Device, Group, Id, MFAMethod, Settings, User, WebAuthn, WebHook, WireguardNetwork, + models::{ + authentication_key::{AuthenticationKey, AuthenticationKeyType}, + oauth2client::OAuth2Client, + settings::{OpenidUsernameHandling, SmtpEncryption}, + }, + }, + enterprise::{ + db::models::{ + activity_log_stream::{ActivityLogStream, ActivityLogStreamType}, + api_tokens::ApiToken, + openid_provider::{DirectorySyncTarget, DirectorySyncUserBehavior, OpenIdProvider}, + snat::UserSnatBinding, + }, + ldap::sync::SyncStatus, + }, + events::ClientMFAMethod, +}; + +#[derive(Serialize)] +pub struct LoginFailedMetadata { + pub message: String, +} #[derive(Serialize)] pub struct MfaLoginMetadata { @@ -6,75 +32,144 @@ pub struct MfaLoginMetadata { } #[derive(Serialize)] -pub struct DeviceAddedMetadata { - pub device_names: Vec, +pub struct MfaLoginFailedMetadata { + pub mfa_method: MFAMethod, + pub message: String, } #[derive(Serialize)] -pub struct DeviceRemovedMetadata { - pub device_names: Vec, +pub struct UserNoSecrets { + pub id: Id, + pub username: String, + pub last_name: String, + pub first_name: String, + pub email: String, + pub phone: Option, + pub mfa_enabled: bool, + pub is_active: bool, + pub from_ldap: bool, + pub ldap_pass_randomized: bool, + pub ldap_rdn: Option, + pub openid_sub: Option, + pub totp_enabled: bool, + pub email_mfa_enabled: bool, + pub mfa_method: MFAMethod, +} + +impl From> for UserNoSecrets { + fn from(value: User) -> Self { + Self { + id: value.id, + username: value.username, + last_name: value.last_name, + first_name: value.first_name, + email: value.email, + phone: value.phone, + mfa_enabled: value.mfa_enabled, + is_active: value.is_active, + from_ldap: value.from_ldap, + ldap_pass_randomized: value.ldap_pass_randomized, + ldap_rdn: value.ldap_rdn, + openid_sub: value.openid_sub, + totp_enabled: value.totp_enabled, + email_mfa_enabled: value.email_mfa_enabled, + mfa_method: value.mfa_method, + } + } } #[derive(Serialize)] -pub struct DeviceModifiedMetadata { - pub device_names: Vec, +pub struct DeviceMetadata { + pub owner: UserNoSecrets, + pub device: Device, } #[derive(Serialize)] -pub struct NetworkDeviceAddedMetadata { - pub device_id: Id, - pub device_name: String, - pub location_id: Id, - pub location: String, +pub struct DeviceModifiedMetadata { + pub owner: UserNoSecrets, + pub before: Device, + pub after: Device, } #[derive(Serialize)] -pub struct NetworkDeviceRemovedMetadata { - pub device_id: Id, - pub device_name: String, - pub location_id: Id, - pub location: String, +pub struct NetworkDeviceMetadata { + pub device: Device, + pub location: WireguardNetwork, } #[derive(Serialize)] pub struct NetworkDeviceModifiedMetadata { - pub device_id: Id, - pub device_name: String, - pub location_id: Id, - pub location: String, + pub location: WireguardNetwork, + pub before: Device, + pub after: Device, } #[derive(Serialize)] -pub struct UserAddedMetadata { - pub username: String, +pub struct UserMetadata { + pub user: UserNoSecrets, } #[derive(Serialize)] pub struct UserModifiedMetadata { - pub username: String, + pub before: UserNoSecrets, + pub after: UserNoSecrets, } #[derive(Serialize)] -pub struct UserRemovedMetadata { - pub username: String, +pub struct UserGroupsModifiedMetadata { + pub user: UserNoSecrets, + pub before: Vec, + pub after: Vec, } - #[derive(Serialize)] -pub struct MfaSecurityKeyRemovedMetadata { - pub key_id: Id, - pub key_name: String, +pub struct MfaSecurityKeyMetadata { + pub key: WebAuthnNoSecrets, } +// Avoid storing secrets in metadata #[derive(Serialize)] -pub struct MfaSecurityKeyAddedMetadata { - pub key_id: Id, - pub key_name: String, +pub struct WebAuthnNoSecrets { + pub id: Id, + pub user_id: Id, + pub name: String, +} + +impl From> for WebAuthnNoSecrets { + fn from(value: WebAuthn) -> Self { + Self { + id: value.id, + user_id: value.user_id, + name: value.name, + } + } } #[derive(Serialize)] pub struct ActivityLogStreamMetadata { + pub stream: ActivityLogStreamNoSecrets, +} + +#[derive(Serialize)] +pub struct ActivityLogStreamModifiedMetadata { + pub before: ActivityLogStreamNoSecrets, + pub after: ActivityLogStreamNoSecrets, +} + +#[derive(Serialize)] +pub struct ActivityLogStreamNoSecrets { pub id: Id, pub name: String, + pub stream_type: ActivityLogStreamType, +} + +impl From> for ActivityLogStreamNoSecrets { + fn from(value: ActivityLogStream) -> Self { + Self { + id: value.id, + name: value.name, + stream_type: value.stream_type, + } + } } #[derive(Serialize)] @@ -87,10 +182,384 @@ pub struct VpnClientMetadata { pub struct VpnClientMfaMetadata { pub location: WireguardNetwork, pub device: Device, - pub method: MFAMethod, + pub method: ClientMFAMethod, +} + +#[derive(Serialize)] +pub struct VpnClientMfaFailedMetadata { + pub location: WireguardNetwork, + pub device: Device, + pub method: ClientMFAMethod, + pub message: String, } #[derive(Serialize)] pub struct EnrollmentDeviceAddedMetadata { pub device: Device, } + +#[derive(Serialize)] +pub struct EnrollmentTokenMetadata { + pub user: UserNoSecrets, +} + +#[derive(Serialize)] +pub struct VpnLocationMetadata { + pub location: WireguardNetwork, +} + +#[derive(Serialize)] +pub struct VpnLocationModifiedMetadata { + pub before: WireguardNetwork, + pub after: WireguardNetwork, +} + +#[derive(Serialize)] +pub struct ApiTokenMetadata { + pub owner: UserNoSecrets, + pub token: ApiTokenNoSecrets, +} + +#[derive(Serialize)] +pub struct ApiTokenNoSecrets { + id: Id, + pub user_id: Id, + pub created_at: NaiveDateTime, + pub name: String, +} + +impl From> for ApiTokenNoSecrets { + fn from(value: ApiToken) -> Self { + Self { + id: value.id, + user_id: value.user_id, + created_at: value.created_at, + name: value.name, + } + } +} + +#[derive(Serialize)] +pub struct ApiTokenRenamedMetadata { + pub owner: UserNoSecrets, + pub token: ApiTokenNoSecrets, + pub old_name: String, + pub new_name: String, +} + +#[derive(Serialize)] +pub struct OpenIdAppMetadata { + pub app: OAuth2ClientNoSecrets, +} + +#[derive(Serialize)] +pub struct OAuth2ClientNoSecrets { + pub id: Id, + pub client_id: String, // unique + pub redirect_uri: Vec, + pub scope: Vec, + pub name: String, + pub enabled: bool, +} + +impl From> for OAuth2ClientNoSecrets { + fn from(value: OAuth2Client) -> Self { + Self { + id: value.id, + client_id: value.client_id, + redirect_uri: value.redirect_uri, + scope: value.scope, + name: value.name, + enabled: value.enabled, + } + } +} + +#[derive(Serialize)] +pub struct OpenIdAppModifiedMetadata { + pub before: OAuth2ClientNoSecrets, + pub after: OAuth2ClientNoSecrets, +} + +#[derive(Serialize)] +pub struct OpenIdAppStateChangedMetadata { + pub app: OAuth2ClientNoSecrets, + pub enabled: bool, +} + +#[derive(Serialize)] +pub struct OpenIdProviderMetadata { + pub provider: OpenIdProviderNoSecrets, +} + +#[derive(Serialize)] +pub struct OpenIdProviderNoSecrets { + pub id: Id, + pub name: String, + pub base_url: String, + pub client_id: String, + pub display_name: Option, + pub google_service_account_email: Option, + pub admin_email: Option, + pub directory_sync_enabled: bool, + pub directory_sync_interval: i32, + pub directory_sync_user_behavior: DirectorySyncUserBehavior, + pub directory_sync_admin_behavior: DirectorySyncUserBehavior, + pub directory_sync_target: DirectorySyncTarget, + pub okta_dirsync_client_id: Option, + pub directory_sync_group_match: Vec, +} + +impl From> for OpenIdProviderNoSecrets { + fn from(value: OpenIdProvider) -> Self { + Self { + id: value.id, + name: value.name, + base_url: value.base_url, + client_id: value.client_id, + display_name: value.display_name, + google_service_account_email: value.google_service_account_email, + admin_email: value.admin_email, + directory_sync_enabled: value.directory_sync_enabled, + directory_sync_interval: value.directory_sync_interval, + directory_sync_user_behavior: value.directory_sync_user_behavior, + directory_sync_admin_behavior: value.directory_sync_admin_behavior, + directory_sync_target: value.directory_sync_target, + okta_dirsync_client_id: value.okta_dirsync_client_id, + directory_sync_group_match: value.directory_sync_group_match, + } + } +} + +#[derive(Serialize)] +pub struct SettingsUpdateMetadata { + pub before: SettingsNoSecrets, + pub after: SettingsNoSecrets, +} + +#[derive(Serialize)] +pub struct SettingsNoSecrets { + // Modules + pub openid_enabled: bool, + pub wireguard_enabled: bool, + pub webhooks_enabled: bool, + pub worker_enabled: bool, + // MFA + pub challenge_template: String, + // Branding + pub instance_name: String, + pub main_logo_url: String, + pub nav_logo_url: String, + // SMTP + pub smtp_server: Option, + pub smtp_port: Option, + pub smtp_encryption: SmtpEncryption, + pub smtp_user: Option, + pub smtp_sender: Option, + // Enrollment + pub enrollment_vpn_step_optional: bool, + pub enrollment_welcome_message: Option, + pub enrollment_welcome_email: Option, + pub enrollment_welcome_email_subject: Option, + pub enrollment_use_welcome_message_as_email: bool, + // LDAP + pub ldap_url: Option, + pub ldap_bind_username: Option, + pub ldap_group_search_base: Option, + pub ldap_user_search_base: Option, + // The structural user class + pub ldap_user_obj_class: Option, + // The structural group class + pub ldap_group_obj_class: Option, + pub ldap_username_attr: Option, + pub ldap_groupname_attr: Option, + pub ldap_group_member_attr: Option, + pub ldap_member_attr: Option, + pub ldap_use_starttls: bool, + pub ldap_tls_verify_cert: bool, + pub ldap_sync_status: SyncStatus, + pub ldap_enabled: bool, + pub ldap_sync_enabled: bool, + pub ldap_is_authoritative: bool, + pub ldap_uses_ad: bool, + pub ldap_sync_interval: i32, + // Additional object classes for users which determine the added attributes + pub ldap_user_auxiliary_obj_classes: Vec, + // The attribute which is used to map LDAP usernames to Defguard usernames + pub ldap_user_rdn_attr: Option, + pub ldap_sync_groups: Vec, + // Whether to create a new account when users try to log in with external OpenID + pub openid_create_account: bool, + pub openid_username_handling: OpenidUsernameHandling, + pub license: Option, + // Gateway disconnect notifications + pub gateway_disconnect_notifications_enabled: bool, + pub gateway_disconnect_notifications_inactivity_threshold: i32, + pub gateway_disconnect_notifications_reconnect_notification_enabled: bool, +} + +impl From for SettingsNoSecrets { + fn from(value: Settings) -> Self { + Self { + openid_enabled: value.openid_enabled, + wireguard_enabled: value.wireguard_enabled, + webhooks_enabled: value.webhooks_enabled, + worker_enabled: value.worker_enabled, + challenge_template: value.challenge_template, + instance_name: value.instance_name, + main_logo_url: value.main_logo_url, + nav_logo_url: value.nav_logo_url, + smtp_server: value.smtp_server, + smtp_port: value.smtp_port, + smtp_encryption: value.smtp_encryption, + smtp_user: value.smtp_user, + smtp_sender: value.smtp_sender, + enrollment_vpn_step_optional: value.enrollment_vpn_step_optional, + enrollment_welcome_message: value.enrollment_welcome_message, + enrollment_welcome_email: value.enrollment_welcome_email, + enrollment_welcome_email_subject: value.enrollment_welcome_email_subject, + enrollment_use_welcome_message_as_email: value.enrollment_use_welcome_message_as_email, + ldap_url: value.ldap_url, + ldap_bind_username: value.ldap_bind_username, + ldap_group_search_base: value.ldap_group_search_base, + ldap_user_search_base: value.ldap_user_search_base, + ldap_user_obj_class: value.ldap_user_obj_class, + ldap_group_obj_class: value.ldap_group_obj_class, + ldap_username_attr: value.ldap_username_attr, + ldap_groupname_attr: value.ldap_groupname_attr, + ldap_group_member_attr: value.ldap_group_member_attr, + ldap_member_attr: value.ldap_member_attr, + ldap_use_starttls: value.ldap_use_starttls, + ldap_tls_verify_cert: value.ldap_tls_verify_cert, + ldap_sync_status: value.ldap_sync_status, + ldap_enabled: value.ldap_enabled, + ldap_sync_enabled: value.ldap_sync_enabled, + ldap_is_authoritative: value.ldap_is_authoritative, + ldap_uses_ad: value.ldap_uses_ad, + ldap_sync_interval: value.ldap_sync_interval, + ldap_user_auxiliary_obj_classes: value.ldap_user_auxiliary_obj_classes, + ldap_user_rdn_attr: value.ldap_user_rdn_attr, + ldap_sync_groups: value.ldap_sync_groups, + openid_create_account: value.openid_create_account, + openid_username_handling: value.openid_username_handling, + license: value.license, + gateway_disconnect_notifications_enabled: value + .gateway_disconnect_notifications_enabled, + gateway_disconnect_notifications_inactivity_threshold: value + .gateway_disconnect_notifications_inactivity_threshold, + gateway_disconnect_notifications_reconnect_notification_enabled: value + .gateway_disconnect_notifications_reconnect_notification_enabled, + } + } +} + +#[derive(Serialize)] +pub struct GroupsBulkAssignedMetadata { + pub users: Vec, + pub groups: Vec>, +} + +#[derive(Serialize)] +pub struct GroupMetadata { + pub group: Group, +} + +#[derive(Serialize)] +pub struct GroupModifiedMetadata { + pub before: Group, + pub after: Group, +} + +#[derive(Serialize)] +pub struct GroupAssignedMetadata { + pub group: Group, + pub user: UserNoSecrets, +} + +#[derive(Serialize)] +pub struct GroupMembersModifiedMetadata { + pub group: Group, + pub added: Vec, + pub removed: Vec, +} +#[derive(Serialize)] +pub struct WebHookMetadata { + pub webhook: WebHook, +} + +#[derive(Serialize)] +pub struct WebHookModifiedMetadata { + pub before: WebHook, + pub after: WebHook, +} + +#[derive(Serialize)] +pub struct WebHookStateChangedMetadata { + pub webhook: WebHook, + pub enabled: bool, +} + +#[derive(Serialize)] +pub struct AuthenticationKeyMetadata { + pub key: AuthenticationKeyNoSecrets, +} + +#[derive(Serialize)] +pub struct AuthenticationKeyNoSecrets { + pub id: Id, + pub yubikey_id: Option, + pub name: Option, + pub user_id: Id, + pub key_type: AuthenticationKeyType, +} + +impl From> for AuthenticationKeyNoSecrets { + fn from(value: AuthenticationKey) -> Self { + Self { + id: value.id, + yubikey_id: value.yubikey_id, + name: value.name, + user_id: value.user_id, + key_type: value.key_type, + } + } +} + +#[derive(Serialize)] +pub struct AuthenticationKeyRenamedMetadata { + pub key: AuthenticationKeyNoSecrets, + pub old_name: Option, + pub new_name: Option, +} + +#[derive(Serialize)] +pub struct PasswordChangedByAdminMetadata { + pub user: UserNoSecrets, +} + +#[derive(Serialize)] +pub struct PasswordResetMetadata { + pub user: UserNoSecrets, +} + +#[derive(Serialize)] +pub struct UserMfaDisabledMetadata { + pub user: UserNoSecrets, +} + +#[derive(Serialize)] +pub struct ClientConfigurationTokenMetadata { + pub user: UserNoSecrets, +} +#[derive(Serialize)] +pub struct UserSnatBindingMetadata { + pub user: UserNoSecrets, + pub binding: UserSnatBinding, +} + +#[derive(Serialize)] +pub struct UserSnatBindingModifiedMetadata { + pub user: UserNoSecrets, + pub before: UserSnatBinding, + pub after: UserSnatBinding, +} diff --git a/crates/defguard_core/src/db/models/activity_log/mod.rs b/crates/defguard_core/src/db/models/activity_log/mod.rs index 8239a5eeb1..a175e50c2e 100644 --- a/crates/defguard_core/src/db/models/activity_log/mod.rs +++ b/crates/defguard_core/src/db/models/activity_log/mod.rs @@ -34,6 +34,7 @@ pub enum EventType { UserLogout, // mfa management MfaDisabled, + UserMfaDisabled, MfaTotpDisabled, MfaTotpEnabled, MfaEmailDisabled, @@ -44,6 +45,10 @@ pub enum EventType { UserAdded, UserRemoved, UserModified, + UserGroupsModified, + PasswordChanged, + PasswordChangedByAdmin, + PasswordReset, // device management DeviceAdded, DeviceRemoved, @@ -55,10 +60,15 @@ pub enum EventType { ActivityLogStreamCreated, ActivityLogStreamModified, ActivityLogStreamRemoved, + ClientConfigurationTokenAdded, // OpenID app management OpenIdAppAdded, OpenIdAppRemoved, OpenIdAppModified, + OpenIdAppStateChanged, + // OpenID provider management + OpenIdProviderRemoved, + OpenIdProviderModified, // VPN location management VpnLocationAdded, VpnLocationRemoved, @@ -70,12 +80,42 @@ pub enum EventType { VpnClientDisconnectedMfa, VpnClientMfaFailed, // Enrollment events + EnrollmentTokenAdded, EnrollmentStarted, EnrollmentDeviceAdded, EnrollmentCompleted, PasswordResetRequested, PasswordResetStarted, PasswordResetCompleted, + // API token management, + ApiTokenAdded, + ApiTokenRemoved, + ApiTokenRenamed, + // Settings management + SettingsUpdated, + SettingsUpdatedPartial, + SettingsDefaultBrandingRestored, + // Groups management + GroupsBulkAssigned, + GroupAdded, + GroupModified, + GroupRemoved, + GroupMemberAdded, + GroupMemberRemoved, + GroupMembersModified, + // WebHook management + WebHookAdded, + WebHookModified, + WebHookRemoved, + WebHookStateChanged, + // Authentication key management + AuthenticationKeyAdded, + AuthenticationKeyRemoved, + AuthenticationKeyRenamed, + // User SNAT bindings management + UserSnatBindingAdded, + UserSnatBindingRemoved, + UserSnatBindingModified, } #[derive(Model, FromRow, Serialize)] @@ -85,11 +125,13 @@ pub struct ActivityLogEvent { pub timestamp: NaiveDateTime, pub user_id: Id, pub username: String, + pub location: Option, pub ip: IpNetwork, #[model(enum)] pub event: EventType, #[model(enum)] pub module: ActivityLogModule, pub device: String, + pub description: Option, pub metadata: Option, } diff --git a/crates/defguard_core/src/db/models/auth_code.rs b/crates/defguard_core/src/db/models/auth_code.rs index 736ae6092f..6b2fd73b53 100644 --- a/crates/defguard_core/src/db/models/auth_code.rs +++ b/crates/defguard_core/src/db/models/auth_code.rs @@ -1,6 +1,6 @@ use chrono::Utc; use model_derive::Model; -use sqlx::{query_as, Error as SqlxError, PgPool}; +use sqlx::{Error as SqlxError, PgPool, query_as}; use crate::{ db::{Id, NoId}, diff --git a/crates/defguard_core/src/db/models/authentication_key.rs b/crates/defguard_core/src/db/models/authentication_key.rs index cf877ca700..84ddd50183 100644 --- a/crates/defguard_core/src/db/models/authentication_key.rs +++ b/crates/defguard_core/src/db/models/authentication_key.rs @@ -1,5 +1,7 @@ +use std::fmt::Display; + use model_derive::Model; -use sqlx::{query_as, Error as SqlxError, PgExecutor, Type}; +use sqlx::{Error as SqlxError, PgExecutor, Type, query_as}; use crate::db::{Id, NoId}; @@ -11,16 +13,25 @@ pub enum AuthenticationKeyType { Gpg, } -#[derive(Deserialize, Model, Serialize)] +impl Display for AuthenticationKeyType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthenticationKeyType::Ssh => write!(f, "SSH"), + AuthenticationKeyType::Gpg => write!(f, "GPG"), + } + } +} + +#[derive(Clone, Debug, Deserialize, Model, Serialize)] #[table(authentication_key)] -pub(crate) struct AuthenticationKey { - id: I, - pub yubikey_id: Option, +pub struct AuthenticationKey { + pub(crate) id: I, + pub(crate) yubikey_id: Option, pub name: Option, - pub user_id: Id, - pub key: String, + pub(crate) user_id: Id, + pub(crate) key: String, #[model(enum)] - key_type: AuthenticationKeyType, + pub key_type: AuthenticationKeyType, } impl AuthenticationKey { 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 be15f9476c..8977f70288 100644 --- a/crates/defguard_core/src/db/models/device.rs +++ b/crates/defguard_core/src/db/models/device.rs @@ -1,6 +1,6 @@ use std::{fmt, net::IpAddr}; -use base64::{prelude::BASE64_STANDARD, Engine}; +use base64::{Engine, prelude::BASE64_STANDARD}; #[cfg(test)] use chrono::NaiveDate; use chrono::{NaiveDateTime, Utc}; @@ -8,24 +8,24 @@ use ipnetwork::IpNetwork; use model_derive::Model; #[cfg(test)] use rand::{ + Rng, distributions::{Alphanumeric, DistString, Standard}, prelude::Distribution, - Rng, }; use sqlx::{ - postgres::types::PgInterval, query, query_as, Error as SqlxError, FromRow, PgConnection, - PgExecutor, PgPool, Type, + Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, + postgres::types::PgInterval, query, query_as, }; use thiserror::Error; use utoipa::ToSchema; use super::{ error::ModelError, - wireguard::{NetworkAddressError, WireguardNetwork, WIREGUARD_MAX_HANDSHAKE}, + wireguard::{LocationMfaMode, NetworkAddressError, WIREGUARD_MAX_HANDSHAKE, WireguardNetwork}, }; use crate::{ - db::{Id, NoId, User}, AsCsv, KEY_LENGTH, + db::{Id, NoId, User}, }; #[derive(Serialize, ToSchema)] @@ -40,15 +40,16 @@ pub struct DeviceConfig { pub(crate) allowed_ips: Vec, pub(crate) pubkey: String, pub(crate) dns: Option, - pub(crate) mfa_enabled: bool, pub(crate) keepalive_interval: i32, + pub(crate) location_mfa_mode: LocationMfaMode, } // 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, @@ -103,10 +104,10 @@ impl fmt::Display for Device { impl Distribution> for Standard { fn sample(&self, rng: &mut R) -> Device { Device { - id: rng.gen(), + id: rng.r#gen(), name: Alphanumeric.sample_string(rng, 8), wireguard_pubkey: Alphanumeric.sample_string(rng, 32), - user_id: rng.gen(), + user_id: rng.r#gen(), created: NaiveDate::from_ymd_opt( rng.gen_range(2000..2026), rng.gen_range(1..13), @@ -124,9 +125,9 @@ impl Distribution> for Standard { _ => DeviceType::User, }, description: rng - .gen::() + .r#gen::() .then_some(Alphanumeric.sample_string(rng, 20)), - configured: rng.gen(), + configured: rng.r#gen(), } } } @@ -499,8 +500,8 @@ impl WireguardNetworkDevice { query_as!( WireguardNetwork, "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, \ - connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, \ - acl_enabled, acl_default_allow \ + connected_at, keepalive_interval, peer_disconnect_threshold, \ + acl_enabled, acl_default_allow, location_mfa_mode \"location_mfa_mode: LocationMfaMode\" \ FROM wireguard_network WHERE id = $1", self.wireguard_network_id ) @@ -692,8 +693,8 @@ impl Device { allowed_ips: network.allowed_ips.clone(), pubkey: network.pubkey.clone(), dns: network.dns.clone(), - mfa_enabled: network.mfa_enabled, keepalive_interval: network.keepalive_interval, + location_mfa_mode: network.location_mfa_mode.clone(), }; Ok((device_network_info, device_config)) @@ -725,8 +726,8 @@ impl Device { allowed_ips: network.allowed_ips.clone(), pubkey: network.pubkey.clone(), dns: network.dns.clone(), - mfa_enabled: network.mfa_enabled, keepalive_interval: network.keepalive_interval, + location_mfa_mode: network.location_mfa_mode.clone(), }; Ok((device_network_info, device_config)) @@ -787,8 +788,8 @@ impl Device { allowed_ips: network.allowed_ips, pubkey: network.pubkey, dns: network.dns, - mfa_enabled: network.mfa_enabled, keepalive_interval: network.keepalive_interval, + location_mfa_mode: network.location_mfa_mode.clone(), }); } } @@ -934,8 +935,8 @@ impl Device { query_as!( WireguardNetwork, "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, \ - connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, \ - acl_enabled, acl_default_allow \ + connected_at, keepalive_interval, peer_disconnect_threshold, \ + acl_enabled, acl_default_allow, location_mfa_mode \"location_mfa_mode: LocationMfaMode\" \ FROM wireguard_network WHERE id IN \ (SELECT wireguard_network_id FROM wireguard_network_device WHERE device_id = $1 ORDER BY id LIMIT 1)", self.id @@ -1012,7 +1013,7 @@ mod test { use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; - use crate::db::{setup_pool, User}; + use crate::db::{User, setup_pool}; impl Device { /// Create new device and assign IP in a given network diff --git a/crates/defguard_core/src/db/models/device_login.rs b/crates/defguard_core/src/db/models/device_login.rs index f94dc1a35a..e3a8e5b7d6 100644 --- a/crates/defguard_core/src/db/models/device_login.rs +++ b/crates/defguard_core/src/db/models/device_login.rs @@ -2,7 +2,7 @@ use std::fmt; use chrono::{NaiveDateTime, Utc}; use model_derive::Model; -use sqlx::{query_as, Error as SqlxError, PgPool}; +use sqlx::{Error as SqlxError, PgPool, query_as}; use crate::db::{Id, NoId}; diff --git a/crates/defguard_core/src/db/models/enrollment.rs b/crates/defguard_core/src/db/models/enrollment.rs index 4ac766f4f5..8204991123 100644 --- a/crates/defguard_core/src/db/models/enrollment.rs +++ b/crates/defguard_core/src/db/models/enrollment.rs @@ -1,19 +1,19 @@ use chrono::{NaiveDateTime, TimeDelta, Utc}; use reqwest::Url; -use sqlx::{query, query_as, Error as SqlxError, PgConnection, PgExecutor, PgPool}; -use tera::{Context, Tera}; +use sqlx::{Error as SqlxError, PgConnection, PgExecutor, PgPool, query, query_as}; +use tera::Context; use thiserror::Error; use tokio::sync::mpsc::UnboundedSender; use tonic::{Code, Status}; -use super::{settings::Settings, User}; +use super::{User, settings::Settings}; use crate::{ + VERSION, db::Id, mail::Mail, random::gen_alphanumeric, server_config, - templates::{self, TemplateError}, - VERSION, + 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 29a02ea067..6dadfbf904 100644 --- a/crates/defguard_core/src/db/models/group.rs +++ b/crates/defguard_core/src/db/models/group.rs @@ -1,10 +1,10 @@ use std::fmt; use model_derive::Model; -use sqlx::{query, query_as, query_scalar, Error as SqlxError, FromRow, PgConnection, PgExecutor}; +use sqlx::{Error as SqlxError, FromRow, PgConnection, PgExecutor, query, query_as, query_scalar}; use utoipa::ToSchema; -use crate::db::{models::error::ModelError, Id, NoId, User, WireguardNetwork}; +use crate::db::{Id, NoId, User, WireguardNetwork, models::error::ModelError}; #[derive(Debug)] pub enum Permission { @@ -19,7 +19,7 @@ impl fmt::Display for Permission { } } -#[derive(Clone, Debug, Model, ToSchema, FromRow, PartialEq)] +#[derive(Clone, Debug, Model, ToSchema, FromRow, PartialEq, Serialize)] pub struct Group { pub(crate) id: I, pub name: String, @@ -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> @@ -184,14 +184,13 @@ impl WireguardNetwork { /// access to networks based on allowed groups. pub async fn get_allowed_groups( &self, - transaction: &mut PgConnection, + conn: &mut PgConnection, ) -> Result>, ModelError> { debug!("Returning a list of allowed groups for network {self}"); - let admin_groups = - Group::find_by_permission(&mut *transaction, Permission::IsAdmin).await?; + let admin_groups = Group::find_by_permission(&mut *conn, Permission::IsAdmin).await?; // get allowed groups from DB - let mut groups = self.fetch_allowed_groups(&mut *transaction).await?; + let mut groups = self.fetch_allowed_groups(&mut *conn).await?; // if no allowed groups are set then all groups are allowed if groups.is_empty() { @@ -300,7 +299,7 @@ mod test { use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; - use crate::db::{setup_pool, User}; + use crate::db::{User, setup_pool}; #[sqlx::test] async fn test_group(_: PgPoolOptions, options: PgConnectOptions) { @@ -367,19 +366,23 @@ mod test { .unwrap(); user.add_to_group(&pool, &group).await.unwrap(); assert!(!user.is_admin(&pool).await.unwrap()); - assert!(!group - .has_permission(&pool, Permission::IsAdmin) - .await - .unwrap()); + assert!( + !group + .has_permission(&pool, Permission::IsAdmin) + .await + .unwrap() + ); group .set_permission(&pool, Permission::IsAdmin, true) .await .unwrap(); - assert!(group - .has_permission(&pool, Permission::IsAdmin) - .await - .unwrap()); + assert!( + group + .has_permission(&pool, Permission::IsAdmin) + .await + .unwrap() + ); assert!(user.is_admin(&pool).await.unwrap()); let groups = Group::find_by_permission(&pool, Permission::IsAdmin) .await diff --git a/crates/defguard_core/src/db/models/mod.rs b/crates/defguard_core/src/db/models/mod.rs index ca683f7119..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; @@ -25,7 +26,7 @@ pub mod yubikey; use std::collections::HashSet; -use sqlx::{query_as, Error as SqlxError, PgConnection, PgPool}; +use sqlx::{Error as SqlxError, PgConnection, PgPool, query_as}; use utoipa::ToSchema; use self::{ @@ -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/oauth2authorizedapp.rs b/crates/defguard_core/src/db/models/oauth2authorizedapp.rs index 094d4738d8..421a93437b 100644 --- a/crates/defguard_core/src/db/models/oauth2authorizedapp.rs +++ b/crates/defguard_core/src/db/models/oauth2authorizedapp.rs @@ -1,5 +1,5 @@ use model_derive::Model; -use sqlx::{query_as, Error as SqlxError, PgPool}; +use sqlx::{Error as SqlxError, PgPool, query_as}; use crate::db::{Id, NoId}; diff --git a/crates/defguard_core/src/db/models/oauth2client.rs b/crates/defguard_core/src/db/models/oauth2client.rs index 91d40f9a27..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::{query_as, Error as SqlxError, PgPool}; +use sqlx::{Error as SqlxError, PgExecutor, PgPool, query_as}; use super::NewOpenIDClient; use crate::{ @@ -7,7 +7,7 @@ use crate::{ random::gen_alphanumeric, }; -#[derive(Deserialize, Model, Serialize)] +#[derive(Clone, Debug, Deserialize, Model, Serialize)] pub struct OAuth2Client { pub id: I, pub client_id: String, // unique @@ -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/oauth2token.rs b/crates/defguard_core/src/db/models/oauth2token.rs index 1639750ecc..3e183ffb06 100644 --- a/crates/defguard_core/src/db/models/oauth2token.rs +++ b/crates/defguard_core/src/db/models/oauth2token.rs @@ -1,5 +1,5 @@ use chrono::{TimeDelta, Utc}; -use sqlx::{query, query_as, Error as SqlxError, PgPool}; +use sqlx::{Error as SqlxError, PgPool, query, query_as}; use crate::{db::Id, random::gen_alphanumeric, server_config}; diff --git a/crates/defguard_core/src/db/models/polling_token.rs b/crates/defguard_core/src/db/models/polling_token.rs index cd20e62ee8..59fcaba079 100644 --- a/crates/defguard_core/src/db/models/polling_token.rs +++ b/crates/defguard_core/src/db/models/polling_token.rs @@ -1,6 +1,6 @@ use chrono::{NaiveDateTime, Utc}; use model_derive::Model; -use sqlx::{query_as, Error as SqlxError, PgExecutor, PgPool}; +use sqlx::{Error as SqlxError, PgExecutor, PgPool, query_as}; use crate::{ db::{Id, NoId}, diff --git a/crates/defguard_core/src/db/models/session.rs b/crates/defguard_core/src/db/models/session.rs index d8fba9aea4..8fe3945aa0 100644 --- a/crates/defguard_core/src/db/models/session.rs +++ b/crates/defguard_core/src/db/models/session.rs @@ -1,5 +1,5 @@ use chrono::{NaiveDateTime, TimeDelta, Utc}; -use sqlx::{query, query_as, Error as SqlxError, PgExecutor, PgPool, Type}; +use sqlx::{Error as SqlxError, PgExecutor, PgPool, Type, query, query_as}; use webauthn_rs::prelude::{PasskeyAuthentication, PasskeyRegistration}; use crate::{db::Id, random::gen_alphanumeric, server_config}; diff --git a/crates/defguard_core/src/db/models/settings.rs b/crates/defguard_core/src/db/models/settings.rs index c2622557d5..d74ebd49ee 100644 --- a/crates/defguard_core/src/db/models/settings.rs +++ b/crates/defguard_core/src/db/models/settings.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; -use sqlx::{query, query_as, PgExecutor, PgPool, Type}; +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 6244386b6b..d2e02ee1d5 100644 --- a/crates/defguard_core/src/db/models/user.rs +++ b/crates/defguard_core/src/db/models/user.rs @@ -1,37 +1,38 @@ use std::{collections::HashSet, fmt, time::SystemTime}; use argon2::{ + Argon2, password_hash::{ - errors::Error as HashError, rand_core::OsRng, PasswordHash, PasswordHasher, - PasswordVerifier, SaltString, + PasswordHash, PasswordHasher, PasswordVerifier, SaltString, errors::Error as HashError, + rand_core::OsRng, }, - Argon2, }; use axum::http::StatusCode; use model_derive::Model; #[cfg(test)] use rand::{ + Rng, distributions::{Alphanumeric, DistString, Standard}, prelude::Distribution, - Rng, }; +use serde::Serialize; use sqlx::{ - query, query_as, query_scalar, Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, - Type, + Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, query, query_as, + query_scalar, }; use tokio::sync::broadcast::Sender; -use totp_lite::{totp_custom, Sha1}; +use totp_lite::{Sha1, totp_custom}; use utoipa::ToSchema; use super::{ + MFAInfo, OAuth2AuthorizedAppInfo, SecurityKey, device::{Device, DeviceInfo, DeviceType, UserDevice}, group::Group, webauthn::WebAuthn, - MFAInfo, OAuth2AuthorizedAppInfo, SecurityKey, }; use crate::{ auth::{EMAIL_CODE_DIGITS, TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}, - db::{models::group::Permission, GatewayEvent, Id, NoId, Session, WireguardNetwork}, + db::{GatewayEvent, Id, NoId, Session, WireguardNetwork, models::group::Permission}, enterprise::limits::update_counts, error::WebError, grpc::{ @@ -53,15 +54,7 @@ pub enum MFAMethod { Email, } -impl From for MFAMethod { - fn from(method: MfaMethod) -> Self { - match method { - MfaMethod::Totp => Self::OneTimePassword, - MfaMethod::Email => Self::Email, - } - } -} - +// Web MFA methods impl fmt::Display for MFAMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( @@ -77,6 +70,23 @@ impl fmt::Display for MFAMethod { } } +// Client MFA methods +impl fmt::Display for MfaMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + MfaMethod::Totp => "TOTP", + MfaMethod::Email => "Email", + MfaMethod::Oidc => "OIDC", + MfaMethod::Biometric => "Biometric", + MfaMethod::MobileApprove => "MobileApprove", + } + ) + } +} + // User information ready to be sent as part of diagnostic data. #[derive(Serialize)] pub struct UserDiagnostic { @@ -265,7 +275,7 @@ impl User { /// We assume the user is enrolled if they have a password set /// or they have logged in using an external OIDC. #[must_use] - pub(crate) fn is_enrolled(&self) -> bool { + pub fn is_enrolled(&self) -> bool { self.password_hash.is_some() || self.openid_sub.is_some() || self.from_ldap } @@ -369,7 +379,7 @@ impl User { .await } - /// Verify the state of mfa flags are correct. + /// Verify the state of MFA flags are correct. /// Recovers from invalid mfa_method /// Use this function after removing any of the authentication factors. pub async fn verify_mfa_state(&mut self, pool: &PgPool) -> Result<(), WebError> { @@ -501,7 +511,9 @@ impl User { for location_id in affected_location_ids { if let Some(location) = WireguardNetwork::find_by_id(&mut *conn, location_id).await? { if let Some(firewall_config) = location.try_get_firewall_config(&mut *conn).await? { - debug!("Sending firewall config update for location {location} affected by deleting user {username} devices"); + debug!( + "Sending firewall config update for location {location} affected by deleting user {username} devices" + ); events.push(GatewayEvent::FirewallConfigChanged( location_id, firewall_config, @@ -875,6 +887,23 @@ impl User { .await } + /// Attempts to find user by username and then by email + /// of none is initially found + pub async fn find_by_username_or_email( + conn: &mut PgConnection, + username_or_email: &str, + ) -> Result, SqlxError> { + let maybe_user = Self::find_by_username(&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], @@ -1201,28 +1230,28 @@ impl User { impl Distribution> for Standard { fn sample(&self, rng: &mut R) -> User { User { - id: rng.gen(), + id: rng.r#gen(), username: Alphanumeric.sample_string(rng, 8), password_hash: rng - .gen::() + .r#gen::() .then_some(Alphanumeric.sample_string(rng, 8)), last_name: Alphanumeric.sample_string(rng, 8), first_name: Alphanumeric.sample_string(rng, 8), email: format!("{}@defguard.net", Alphanumeric.sample_string(rng, 6)), // FIXME: generate an actual phone number phone: rng - .gen::() + .r#gen::() .then_some(Alphanumeric.sample_string(rng, 9)), - mfa_enabled: rng.gen(), + mfa_enabled: rng.r#gen(), is_active: true, openid_sub: rng - .gen::() + .r#gen::() .then_some(Alphanumeric.sample_string(rng, 8)), - totp_enabled: rng.gen(), - email_mfa_enabled: rng.gen(), - totp_secret: (0..20).map(|_| rng.gen()).collect(), - email_mfa_secret: (0..20).map(|_| rng.gen()).collect(), - mfa_method: match rng.gen_range(0..4) { + totp_enabled: rng.r#gen(), + email_mfa_enabled: rng.r#gen(), + totp_secret: (0..20).map(|_| rng.r#gen()).collect(), + email_mfa_secret: (0..20).map(|_| rng.r#gen()).collect(), + mfa_method: match rng.r#gen_range(0..4) { 0 => MFAMethod::None, 1 => MFAMethod::Webauthn, 2 => MFAMethod::OneTimePassword, @@ -1244,25 +1273,25 @@ impl Distribution> for Standard { id: NoId, username: Alphanumeric.sample_string(rng, 8), password_hash: rng - .gen::() + .r#gen::() .then_some(Alphanumeric.sample_string(rng, 8)), last_name: Alphanumeric.sample_string(rng, 8), first_name: Alphanumeric.sample_string(rng, 8), email: format!("{}@defguard.net", Alphanumeric.sample_string(rng, 6)), // FIXME: generate an actual phone number phone: rng - .gen::() + .r#gen::() .then_some(Alphanumeric.sample_string(rng, 9)), - mfa_enabled: rng.gen(), + mfa_enabled: rng.r#gen(), is_active: true, openid_sub: rng - .gen::() + .r#gen::() .then_some(Alphanumeric.sample_string(rng, 8)), - totp_enabled: rng.gen(), - email_mfa_enabled: rng.gen(), - totp_secret: (0..20).map(|_| rng.gen()).collect(), - email_mfa_secret: (0..20).map(|_| rng.gen()).collect(), - mfa_method: match rng.gen_range(0..4) { + totp_enabled: rng.r#gen(), + email_mfa_enabled: rng.r#gen(), + totp_secret: (0..20).map(|_| rng.r#gen()).collect(), + email_mfa_secret: (0..20).map(|_| rng.r#gen()).collect(), + mfa_method: match rng.r#gen_range(0..4) { 0 => MFAMethod::None, 1 => MFAMethod::Webauthn, 2 => MFAMethod::OneTimePassword, @@ -1283,9 +1312,9 @@ mod test { use super::*; use crate::{ + SERVER_CONFIG, config::DefGuardConfig, db::{models::settings::initialize_current_settings, setup_pool}, - SERVER_CONFIG, }; #[sqlx::test] @@ -1410,10 +1439,12 @@ mod test { let mut user = fetched_user.unwrap(); assert_eq!(user.recovery_codes.len(), RECOVERY_CODES_COUNT); - assert!(!user - .verify_recovery_code(&pool, "invalid code") - .await - .unwrap()); + assert!( + !user + .verify_recovery_code(&pool, "invalid code") + .await + .unwrap() + ); let codes = user.recovery_codes.clone(); for code in &codes { assert!(user.verify_recovery_code(&pool, code).await.unwrap()); diff --git a/crates/defguard_core/src/db/models/webauthn.rs b/crates/defguard_core/src/db/models/webauthn.rs index 29fbe4695b..64a2c794c9 100644 --- a/crates/defguard_core/src/db/models/webauthn.rs +++ b/crates/defguard_core/src/db/models/webauthn.rs @@ -1,15 +1,15 @@ use model_derive::Model; -use sqlx::{query, query_as, query_scalar, Error as SqlxError, PgExecutor, PgPool}; +use sqlx::{Error as SqlxError, PgExecutor, PgPool, query, query_as, query_scalar}; use webauthn_rs::prelude::Passkey; use super::error::ModelError; use crate::db::{Id, NoId}; -#[derive(Model)] +#[derive(Model, Clone, Debug)] pub struct WebAuthn { - id: I, - pub(crate) user_id: Id, - name: String, + pub id: I, + pub user_id: Id, + pub name: String, // serialize from/to [`Passkey`] pub passkey: Vec, } diff --git a/crates/defguard_core/src/db/models/webhook.rs b/crates/defguard_core/src/db/models/webhook.rs index c9eda286c0..fb7a83dd5b 100644 --- a/crates/defguard_core/src/db/models/webhook.rs +++ b/crates/defguard_core/src/db/models/webhook.rs @@ -1,5 +1,5 @@ use model_derive::Model; -use sqlx::{query_as, Error as SqlxError, FromRow, PgPool}; +use sqlx::{Error as SqlxError, FromRow, PgPool, query_as}; use super::UserInfo; use crate::db::{Id, NoId}; @@ -12,6 +12,7 @@ pub enum AppEvent { UserDeleted(String), HWKeyProvision(HWKeyUserData), } + /// User data send on HWKeyProvision AppEvent #[derive(Debug, Serialize)] pub struct HWKeyUserData { @@ -46,7 +47,7 @@ impl AppEvent { } } -#[derive(Debug, Deserialize, FromRow, Model, Serialize)] +#[derive(Clone, Debug, Deserialize, FromRow, Model, Serialize)] pub struct WebHook { pub id: I, pub url: String, diff --git a/crates/defguard_core/src/db/models/wireguard.rs b/crates/defguard_core/src/db/models/wireguard.rs index 7da00773b8..4c486ffa30 100644 --- a/crates/defguard_core/src/db/models/wireguard.rs +++ b/crates/defguard_core/src/db/models/wireguard.rs @@ -1,18 +1,18 @@ use std::{ collections::HashMap, - fmt, + fmt::{self, Display}, iter::zip, net::{IpAddr, Ipv4Addr}, }; -use base64::prelude::{Engine, BASE64_STANDARD}; +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::{ - postgres::types::PgInterval, query_as, query_scalar, Error as SqlxError, FromRow, PgConnection, - PgExecutor, PgPool, + Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, + postgres::types::PgInterval, query_as, query_scalar, }; use thiserror::Error; use tokio::sync::broadcast::Sender; @@ -20,28 +20,30 @@ use utoipa::ToSchema; use x25519_dalek::{PublicKey, StaticSecret}; use super::{ + UserInfo, device::{ Device, DeviceError, DeviceInfo, DeviceNetworkInfo, DeviceType, WireguardNetworkDevice, }, error::ModelError, user::User, wireguard_peer_stats::WireguardPeerStats, - UserInfo, }; use crate::{ + AsCsv, + auth::{Claims, ClaimsType}, db::{Id, NoId}, enterprise::firewall::FirewallError, grpc::{ - gateway::{send_multiple_wireguard_events, Peer}, - proto::enterprise::firewall::FirewallConfig, - GatewayState, + gateway::{Peer, send_multiple_wireguard_events, state::GatewayState}, + proto::{ + enterprise::firewall::FirewallConfig, proxy::LocationMfaMode as ProtoLocationMfaMode, + }, }, wg_config::ImportedDevice, - AsCsv, }; pub const DEFAULT_KEEPALIVE_INTERVAL: i32 = 25; -pub const DEFAULT_DISCONNECT_THRESHOLD: i32 = 180; +pub const DEFAULT_DISCONNECT_THRESHOLD: i32 = 300; // Used in process of importing network from wireguard config #[derive(Clone, Debug, Deserialize, Serialize)] @@ -83,6 +85,47 @@ pub enum GatewayEvent { FirewallDisabled(Id), } +#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize, ToSchema, Type)] +#[sqlx(type_name = "location_mfa_mode", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum LocationMfaMode { + #[default] + Disabled, + Internal, + External, +} + +impl Display for LocationMfaMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LocationMfaMode::Disabled => write!(f, "MFA disabled"), + LocationMfaMode::Internal => write!(f, "Internal MFA"), + LocationMfaMode::External => write!(f, "External MFA"), + } + } +} + +impl From for LocationMfaMode { + fn from(value: ProtoLocationMfaMode) -> Self { + match value { + ProtoLocationMfaMode::Unspecified | ProtoLocationMfaMode::Disabled => { + LocationMfaMode::Disabled + } + ProtoLocationMfaMode::Internal => LocationMfaMode::Internal, + ProtoLocationMfaMode::External => LocationMfaMode::External, + } + } +} +impl From for ProtoLocationMfaMode { + fn from(value: LocationMfaMode) -> Self { + match value { + LocationMfaMode::Disabled => ProtoLocationMfaMode::Disabled, + LocationMfaMode::Internal => ProtoLocationMfaMode::Internal, + LocationMfaMode::External => ProtoLocationMfaMode::External, + } + } +} + /// Stores configuration required to setup a WireGuard network #[derive(Clone, Debug, Deserialize, Eq, Hash, Model, PartialEq, Serialize, ToSchema)] #[table(wireguard_network)] @@ -102,11 +145,12 @@ pub struct WireguardNetwork { #[schema(value_type = String)] pub allowed_ips: Vec, pub connected_at: Option, - pub mfa_enabled: bool, pub acl_enabled: bool, pub acl_default_allow: bool, pub keepalive_interval: i32, pub peer_disconnect_threshold: i32, + #[model(enum)] + pub location_mfa_mode: LocationMfaMode, } pub struct WireguardKey { @@ -140,11 +184,11 @@ impl Default for WireguardNetwork { dns: Option::default(), allowed_ips: Vec::default(), connected_at: Option::default(), - mfa_enabled: false, keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL, peer_disconnect_threshold: DEFAULT_DISCONNECT_THRESHOLD, acl_default_allow: false, acl_enabled: false, + location_mfa_mode: LocationMfaMode::default(), } } } @@ -169,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)] @@ -190,6 +236,7 @@ pub enum NetworkAddressError { } impl WireguardNetwork { + #[must_use] pub fn new( name: String, address: Vec, @@ -197,15 +244,15 @@ impl WireguardNetwork { endpoint: String, dns: Option, allowed_ips: Vec, - mfa_enabled: bool, keepalive_interval: i32, peer_disconnect_threshold: i32, acl_enabled: bool, acl_default_allow: bool, - ) -> Result { + location_mfa_mode: LocationMfaMode, + ) -> Self { let prvkey = StaticSecret::random_from_rng(OsRng); let pubkey = PublicKey::from(&prvkey); - Ok(Self { + Self { id: NoId, name, address, @@ -216,12 +263,13 @@ impl WireguardNetwork { dns, allowed_ips, connected_at: None, - mfa_enabled, + keepalive_interval, peer_disconnect_threshold, acl_enabled, acl_default_allow, - }) + location_mfa_mode, + } } /// Try to set `address` from `&str`. @@ -250,8 +298,8 @@ impl WireguardNetwork { let networks = query_as!( WireguardNetwork, "SELECT id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, \ - connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, \ - acl_enabled, acl_default_allow \ + connected_at, keepalive_interval, peer_disconnect_threshold, \ + acl_enabled, acl_default_allow, location_mfa_mode \"location_mfa_mode: LocationMfaMode\" \ FROM wireguard_network WHERE name = $1", name ) @@ -464,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() @@ -471,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() } @@ -671,9 +721,10 @@ impl WireguardNetwork { match allowed_devices.get(&existing_device.id) { Some(_) => { info!( - "Device with pubkey {} exists already, assigning IPs {} for new network: {self}", - existing_device.wireguard_pubkey, imported_device.wireguard_ips.as_csv() - ); + "Device with pubkey {} exists already, assigning IPs {} for new network: {self}", + existing_device.wireguard_pubkey, + imported_device.wireguard_ips.as_csv() + ); let wireguard_network_device = WireguardNetworkDevice::new( self.id, existing_device.id, @@ -695,9 +746,9 @@ impl WireguardNetwork { } None => { warn!( - "Device with pubkey {} exists already, but is not allowed in network {self}. Skipping...", - existing_device.wireguard_pubkey - ); + "Device with pubkey {} exists already, but is not allowed in network {self}. Skipping...", + existing_device.wireguard_pubkey + ); } } } @@ -845,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, @@ -1226,6 +1218,49 @@ impl WireguardNetwork { Ok(()) } + + #[must_use] + pub fn mfa_enabled(&self) -> bool { + match self.location_mfa_mode { + LocationMfaMode::Internal | LocationMfaMode::External => true, + LocationMfaMode::Disabled => false, + } + } + + // fetch all locations using external MFA + pub(crate) async fn all_using_external_mfa<'e, E>( + executor: E, + ) -> Result, WireguardNetworkError> + where + E: PgExecutor<'e>, + { + 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\" \ + FROM wireguard_network WHERE location_mfa_mode = 'external'::location_mfa_mode", + ) + .fetch_all(executor) + .await?; + + 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`] @@ -1242,16 +1277,16 @@ impl Default for WireguardNetwork { dns: Option::default(), allowed_ips: Vec::default(), connected_at: Option::default(), - mfa_enabled: false, keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL, peer_disconnect_threshold: DEFAULT_DISCONNECT_THRESHOLD, acl_enabled: false, acl_default_allow: false, + location_mfa_mode: LocationMfaMode::default(), } } } -#[derive(Serialize, Clone, Debug, ToSchema)] +#[derive(Serialize, ToSchema)] pub struct WireguardNetworkInfo { #[serde(flatten)] pub network: WireguardNetwork, @@ -1260,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, @@ -1388,7 +1423,7 @@ mod test { use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; - use crate::db::{setup_pool, Group}; + use crate::db::{Group, setup_pool}; #[sqlx::test] async fn test_connected_at_reconnection(_: PgPoolOptions, options: PgConnectOptions) { @@ -1954,13 +1989,12 @@ mod test { String::new(), None, vec![IpNetwork::from_str("10.1.1.0/24").unwrap()], - false, 300, 300, false, false, + LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); @@ -2086,13 +2120,12 @@ mod test { String::new(), None, vec![IpNetwork::from_str("10.1.1.0/24").unwrap()], - false, 300, 300, false, false, + LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); diff --git a/crates/defguard_core/src/db/models/wireguard_peer_stats.rs b/crates/defguard_core/src/db/models/wireguard_peer_stats.rs index 8ed751105c..7a2f7322e8 100644 --- a/crates/defguard_core/src/db/models/wireguard_peer_stats.rs +++ b/crates/defguard_core/src/db/models/wireguard_peer_stats.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; use humantime::format_duration; use ipnetwork::IpNetwork; use model_derive::Model; -use sqlx::{query, query_as, query_scalar, PgExecutor, PgPool}; +use sqlx::{PgExecutor, PgPool, query, query_as, query_scalar}; use crate::db::{Id, NoId}; diff --git a/crates/defguard_core/src/db/models/yubikey.rs b/crates/defguard_core/src/db/models/yubikey.rs index ca3d90382f..1535297078 100644 --- a/crates/defguard_core/src/db/models/yubikey.rs +++ b/crates/defguard_core/src/db/models/yubikey.rs @@ -1,5 +1,5 @@ use model_derive::Model; -use sqlx::{query, query_as, PgExecutor}; +use sqlx::{PgExecutor, query, query_as}; use crate::db::{Id, NoId}; 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 63013ab687..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 @@ -8,7 +8,7 @@ use tracing::debug; use super::ActivityLogStreamReconfigurationNotification; use crate::enterprise::{ - activity_log_stream::http_stream::{run_http_stream_task, HttpActivityLogStreamConfig}, + activity_log_stream::http_stream::{HttpActivityLogStreamConfig, run_http_stream_task}, db::models::activity_log_stream::{ActivityLogStream, ActivityLogStreamConfig}, is_enterprise_enabled, }; @@ -66,17 +66,18 @@ 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 { - info!("Activity log stream manager cannot start streams, license needs enterprise features enabled."); + info!( + "Activity log stream manager cannot start streams, license needs enterprise features enabled." + ); } // wait for one of the following: @@ -85,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 357fad20de..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 @@ -1,6 +1,6 @@ use std::sync::Arc; -use base64::prelude::{Engine, BASE64_STANDARD}; +use base64::prelude::{BASE64_STANDARD, Engine}; use bytes::Bytes; use reqwest::tls; use tokio::sync::broadcast::Receiver; @@ -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 d4e6cd7d1d..60e781b47c 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -9,19 +9,22 @@ use chrono::NaiveDateTime; use ipnetwork::{IpNetwork, IpNetworkError}; use model_derive::Model; use sqlx::{ - error::ErrorKind, postgres::types::PgRange, query, query_as, query_scalar, Error as SqlxError, - FromRow, PgConnection, PgExecutor, PgPool, Type, + Error as SqlxError, FromRow, PgConnection, PgExecutor, PgPool, Type, error::ErrorKind, + postgres::types::PgRange, query, query_as, query_scalar, }; use thiserror::Error; use crate::{ + DeviceType, appstate::AppState, - db::{Device, GatewayEvent, Group, Id, NoId, User, WireguardNetwork}, + db::{ + Device, GatewayEvent, Group, Id, NoId, User, WireguardNetwork, + models::wireguard::LocationMfaMode, + }, enterprise::{ firewall::FirewallError, handlers::acl::{ApiAclAlias, ApiAclRule, EditAclAlias, EditAclRule}, }, - DeviceType, }; #[derive(Debug, Error)] @@ -567,7 +570,9 @@ pub fn parse_ports(ports: &str) -> Result, AclError> { fn map_relation_error(err: SqlxError, class: &str, id: Id) -> AclError { if let SqlxError::Database(dberror) = &err { if dberror.kind() == ErrorKind::ForeignKeyViolation { - error!("Failed to create ACL related object, foreign key violation: {class}({id}): {dberror}"); + error!( + "Failed to create ACL related object, foreign key violation: {class}({id}): {dberror}" + ); return AclError::InvalidRelationError(format!("{class}({id})")); } } @@ -665,7 +670,9 @@ impl AclRule { .fetch_all(&mut *transaction) .await?; if !invalid_alias_ids.is_empty() { - error!("Cannot use aliases which have not been applied in an ACL rule. Invalid aliases: {invalid_alias_ids:?}"); + error!( + "Cannot use aliases which have not been applied in an ACL rule. Invalid aliases: {invalid_alias_ids:?}" + ); return Err(AclError::CannotUseModifiedAliasInRuleError( invalid_alias_ids, )); @@ -813,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, @@ -900,8 +907,8 @@ impl AclRule { query_as!( WireguardNetwork, "SELECT n.id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, \ - connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, \ - acl_enabled, acl_default_allow \ + connected_at, keepalive_interval, peer_disconnect_threshold, \ + acl_enabled, acl_default_allow, location_mfa_mode \"location_mfa_mode: LocationMfaMode\" \ FROM aclrulenetwork r \ JOIN wireguard_network n \ ON n.id = r.network_id \ @@ -1596,7 +1603,9 @@ impl AclAlias { // check if any rules are using this alias let rules = existing_alias.get_rules(&mut *transaction).await?; if !rules.is_empty() { - error!("Deletion of alias ({id}) failed. Alias is currently used by following ACL rules: {rules:?}"); + error!( + "Deletion of alias ({id}) failed. Alias is currently used by following ACL rules: {rules:?}" + ); return Err(AclError::AliasUsedByRulesError(id)); } 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 34c681f80a..ca828a4238 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl/tests.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl/tests.rs @@ -1,6 +1,6 @@ use std::ops::Bound; -use rand::{thread_rng, Rng}; +use rand::{Rng, thread_rng}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; @@ -177,13 +177,12 @@ async fn test_rule_relations(_: PgPoolOptions, options: PgConnectOptions) { "endpoint1".to_string(), None, Vec::new(), - false, 100, 100, false, false, + LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); @@ -194,13 +193,12 @@ async fn test_rule_relations(_: PgPoolOptions, options: PgConnectOptions) { "endpoint2".to_string(), None, Vec::new(), - false, 200, 200, false, false, + LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); @@ -472,14 +470,14 @@ async fn test_all_allowed_users(_: PgPoolOptions, options: PgConnectOptions) { let mut rng = thread_rng(); // Create test users - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); - let user_3: User = rng.gen(); + let user_3: User = rng.r#gen(); let user_3 = user_3.save(&pool).await.unwrap(); // inactive user - let mut user_4: User = rng.gen(); + let mut user_4: User = rng.r#gen(); user_4.is_active = false; let user_4 = user_4.save(&pool).await.unwrap(); @@ -587,14 +585,14 @@ async fn test_all_denied_users(_: PgPoolOptions, options: PgConnectOptions) { let mut rng = thread_rng(); // Create test users - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); - let user_3: User = rng.gen(); + let user_3: User = rng.r#gen(); let user_3 = user_3.save(&pool).await.unwrap(); // inactive user - let mut user_4: User = rng.gen(); + let mut user_4: User = rng.r#gen(); user_4.is_active = false; let user_4 = user_4.save(&pool).await.unwrap(); diff --git a/crates/defguard_core/src/enterprise/db/models/activity_log_stream.rs b/crates/defguard_core/src/enterprise/db/models/activity_log_stream.rs index d60c36e85a..9bfb86676a 100644 --- a/crates/defguard_core/src/enterprise/db/models/activity_log_stream.rs +++ b/crates/defguard_core/src/enterprise/db/models/activity_log_stream.rs @@ -1,6 +1,6 @@ use model_derive::Model; use serde::Serialize; -use sqlx::{query_as, Error as SqlxError, FromRow, PgExecutor, Type}; +use sqlx::{Error as SqlxError, FromRow, PgExecutor, Type, query_as}; use strum_macros::{Display, EnumString}; use crate::{ @@ -19,7 +19,7 @@ pub enum ActivityLogStreamType { LogstashHttp, } -#[derive(Debug, Serialize, Model, FromRow)] +#[derive(Clone, Debug, Serialize, Model, FromRow)] #[table(activity_log_stream)] pub struct ActivityLogStream { pub id: I, 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 8bb6d879b1..ef1ffebfd4 100644 --- a/crates/defguard_core/src/enterprise/db/models/api_tokens.rs +++ b/crates/defguard_core/src/enterprise/db/models/api_tokens.rs @@ -1,13 +1,13 @@ use chrono::NaiveDateTime; use model_derive::Model; -use sqlx::{query_as, Error as SqlxError, PgExecutor}; +use sqlx::{Error as SqlxError, PgExecutor, query_as}; use crate::db::{Id, NoId}; -#[derive(Deserialize, Model, Serialize)] +#[derive(Clone, Debug, Deserialize, Model, Serialize)] #[table(api_token)] pub struct ApiToken { - id: I, + pub id: I, pub user_id: Id, pub created_at: NaiveDateTime, pub name: String, @@ -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/enterprise_settings.rs b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs index 7c1a85dd1f..f2e85ced8b 100644 --- a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs @@ -1,4 +1,4 @@ -use sqlx::{query, query_as, PgExecutor}; +use sqlx::{PgExecutor, query, query_as}; use struct_patch::Patch; use crate::enterprise::is_enterprise_enabled; diff --git a/crates/defguard_core/src/enterprise/db/models/mod.rs b/crates/defguard_core/src/enterprise/db/models/mod.rs index 36ae9de03e..18af2791d2 100644 --- a/crates/defguard_core/src/enterprise/db/models/mod.rs +++ b/crates/defguard_core/src/enterprise/db/models/mod.rs @@ -3,3 +3,4 @@ pub mod activity_log_stream; pub mod api_tokens; pub mod enterprise_settings; pub mod openid_provider; +pub mod snat; 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 4039727bb3..70fa182087 100644 --- a/crates/defguard_core/src/enterprise/db/models/openid_provider.rs +++ b/crates/defguard_core/src/enterprise/db/models/openid_provider.rs @@ -1,7 +1,7 @@ use std::fmt; use model_derive::Model; -use sqlx::{query, query_as, Error as SqlxError, PgPool, Type}; +use sqlx::{Error as SqlxError, PgExecutor, PgPool, Type, query, query_as}; use crate::db::{Id, NoId}; @@ -11,6 +11,7 @@ use crate::db::{Id, NoId}; // Delete: Delete the user #[derive(Clone, Deserialize, Serialize, PartialEq, Type, Debug)] #[sqlx(type_name = "dirsync_user_behavior", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum DirectorySyncUserBehavior { Keep, Disable, @@ -51,6 +52,7 @@ impl From for DirectorySyncUserBehavior { // Groups: Sync only groups (members without their state) #[derive(Clone, Deserialize, Serialize, PartialEq, Type, Debug)] #[sqlx(type_name = "dirsync_target", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] pub enum DirectorySyncTarget { All, Users, @@ -85,7 +87,7 @@ impl From for DirectorySyncTarget { } } -#[derive(Deserialize, Model, Serialize)] +#[derive(Clone, Debug, Deserialize, Model, Serialize)] pub struct OpenIdProvider { pub id: I, pub name: String, @@ -113,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 { @@ -134,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, @@ -153,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, @@ -182,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) @@ -195,23 +202,32 @@ impl OpenIdProvider { } impl OpenIdProvider { - pub async fn find_by_name(pool: &PgPool, name: &str) -> Result, SqlxError> { + pub(crate) async fn find_by_name<'e, E>( + executor: E, + name: &str, + ) -> Result, SqlxError> + where + E: PgExecutor<'e>, + { query_as!( OpenIdProvider, "SELECT id, name, base_url, client_id, client_secret, display_name, \ - google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, + 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 \ + okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key \ FROM openidprovider WHERE name = $1", name ) - .fetch_optional(pool) + .fetch_optional(executor) .await } - pub async fn get_current(pool: &PgPool) -> Result, SqlxError> { + pub(crate) async fn get_current<'e, E>(executor: E) -> Result, SqlxError> + where + E: PgExecutor<'e>, + { query_as!( OpenIdProvider, "SELECT id, name, base_url, client_id, client_secret, display_name, \ @@ -219,10 +235,10 @@ 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(pool) + .fetch_optional(executor) .await } } diff --git a/crates/defguard_core/src/enterprise/db/models/snat.rs b/crates/defguard_core/src/enterprise/db/models/snat.rs new file mode 100644 index 0000000000..127da55bf9 --- /dev/null +++ b/crates/defguard_core/src/enterprise/db/models/snat.rs @@ -0,0 +1,71 @@ +use std::net::IpAddr; + +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 { + pub id: I, + pub user_id: Id, + pub location_id: Id, + #[model(ip)] + #[schema(value_type = String)] + pub public_ip: IpAddr, +} + +impl UserSnatBinding { + #[must_use] + pub fn new(user_id: Id, location_id: Id, public_ip: IpAddr) -> Self { + Self { + id: NoId, + user_id, + location_id, + public_ip, + } + } +} + +impl UserSnatBinding { + pub async fn find_binding<'e, E>( + executor: E, + location_id: Id, + user_id: Id, + ) -> Result + where + E: PgExecutor<'e>, + { + let binding = query_as!(Self, + "SELECT id, user_id, location_id, \"public_ip\" \"public_ip: IpAddr\" FROM user_snat_binding WHERE location_id = $1 AND user_id = $2", + location_id, user_id + ).fetch_one(executor).await?; + + Ok(binding) + } + + pub async fn all_for_location<'e, E>( + executor: E, + location_id: Id, + ) -> Result, sqlx::Error> + where + E: PgExecutor<'e>, + { + let bindings = query_as!(Self, + "SELECT id, user_id, location_id, \"public_ip\" \"public_ip: IpAddr\" FROM user_snat_binding WHERE location_id = $1", + location_id + ).fetch_all(executor).await?; + + Ok(bindings) + } + + pub fn update_ip(&mut self, new_public_ip: IpAddr) { + self.public_ip = new_public_ip; + } +} diff --git a/crates/defguard_core/src/enterprise/directory_sync/google.rs b/crates/defguard_core/src/enterprise/directory_sync/google.rs index 8c35acba20..4f7a5139fa 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/google.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/google.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; use chrono::{DateTime, TimeDelta, Utc}; -use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use tokio::time::sleep; use super::{ - make_get_request, parse_response, DirectoryGroup, DirectorySync, DirectorySyncError, - DirectoryUser, REQUEST_PAGINATION_SLOWDOWN, REQUEST_TIMEOUT, + DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, REQUEST_PAGINATION_SLOWDOWN, + REQUEST_TIMEOUT, make_get_request, parse_response, }; const SCOPES: &str = "openid email profile https://www.googleapis.com/auth/admin.directory.customer.readonly https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/admin.directory.user.readonly"; @@ -105,6 +105,7 @@ impl From for DirectoryUser { Self { email: val.primary_email, active: !val.suspended, + id: None, } } } @@ -213,7 +214,9 @@ impl GoogleDirectorySync { } if let Some(next_page_token) = response.page_token { - debug!("Found next page of results, using the following token to query it: {next_page_token}"); + debug!( + "Found next page of results, using the following token to query it: {next_page_token}" + ); query.insert("pageToken".to_string(), next_page_token); } else { debug!("No more pages of results found, finishing query."); @@ -263,7 +266,9 @@ impl GoogleDirectorySync { } if let Some(next_page_token) = response.page_token { - debug!("Found next page of results, using the following token to query it: {next_page_token}"); + debug!( + "Found next page of results, using the following token to query it: {next_page_token}" + ); query.insert("pageToken".to_string(), next_page_token); } else { debug!("No more pages of results found, finishing query."); @@ -323,7 +328,9 @@ impl GoogleDirectorySync { } if let Some(next_page_token) = response.page_token { - debug!("Found next page of results, using the following token to query it: {next_page_token}"); + debug!( + "Found next page of results, using the following token to query it: {next_page_token}" + ); query.insert("pageToken".to_string(), next_page_token); } else { debug!("No more pages of results found, finishing query."); @@ -393,7 +400,9 @@ impl GoogleDirectorySync { } if let Some(next_page_token) = response.page_token { - debug!("Found next page of results, using the following token to query it: {next_page_token}"); + debug!( + "Found next page of results, using the following token to query it: {next_page_token}" + ); query.insert("pageToken".to_string(), next_page_token); } else { debug!("No more pages of results found, finishing query."); @@ -417,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 7437db5809..48ebef13a5 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/microsoft.rs @@ -3,8 +3,8 @@ use serde::Deserialize; use tokio::time::sleep; use super::{ - make_get_request, parse_response, DirectoryGroup, DirectorySync, DirectorySyncError, - DirectoryUser, REQUEST_PAGINATION_SLOWDOWN, + DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, REQUEST_PAGINATION_SLOWDOWN, + make_get_request, parse_response, }; use crate::enterprise::directory_sync::REQUEST_TIMEOUT; @@ -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; } } @@ -342,7 +343,9 @@ impl MicrosoftDirectorySync { } else if let Some(user) = response.value.into_iter().next() { user.id } else { - debug!("User with email {user_email} not found in Microsoft API, trying fallback search of additional email addresses",); + debug!( + "User with email {user_email} not found in Microsoft API, trying fallback search of additional email addresses", + ); let user_search = USER_SEARCH_URL_FALLBACK .replace("{email}", user_email) .replace("{query_fields}", USER_QUERY_FIELDS); @@ -497,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()) } @@ -508,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 a44be508a3..78e25f8a96 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/mod.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/mod.rs @@ -5,7 +5,7 @@ use std::{ use paste::paste; use reqwest::header::AUTHORIZATION; -use sqlx::{error::Error as SqlxError, PgPool}; +use sqlx::{PgPool, error::Error as SqlxError}; use thiserror::Error; use tokio::sync::broadcast::Sender; @@ -30,19 +30,21 @@ const REQUEST_PAGINATION_SLOWDOWN: Duration = Duration::from_millis(100); pub enum DirectorySyncError { #[error("Database error: {0}")] DbError(#[from] SqlxError), - #[error("Access token has expired or is not present. An issue may have occured while trying to obtain a new one.")] + #[error( + "Access token has expired or is not present. An issue may have occured while trying to obtain a new one." + )] AccessTokenExpired, #[error("Processing a request to the provider's API failed: {0}")] RequestError(String), - #[error( - "Failed to build a JWT token, required for communicating with the provider's API: {0}" - )] + #[error("Failed to build a JWT token, required for communicating with the provider's API: {0}")] JWTError(#[from] jsonwebtoken::errors::Error), #[error("The selected provider {0} is not supported for directory sync")] UnsupportedProvider(String), #[error("Directory sync is not configured")] NotConfigured, - #[error("Couldn't map provider's group to a Defguard group as it doesn't exist. There may be an issue with automatic group creation. Error details: {0}")] + #[error( + "Couldn't map provider's group to a Defguard group as it doesn't exist. There may be an issue with automatic group creation. Error details: {0}" + )] DefGuardGroupNotFound(String), #[error("The provided provider configuration is invalid: {0}")] InvalidProviderConfiguration(String), @@ -63,7 +65,9 @@ pub enum DirectorySyncError { impl From for DirectorySyncError { fn from(err: reqwest::Error) -> Self { if err.is_decode() { - Self::RequestError(format!("There was an error while trying to decode provider's response, it may be malformed: {err}")) + Self::RequestError(format!( + "There was an error while trying to decode provider's response, it may be malformed: {err}" + )) } else if err.is_timeout() { Self::RequestError(format!( "The request to the provider's API timed out: {err}" @@ -75,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 { @@ -88,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, @@ -102,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 @@ -151,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, )* } } @@ -195,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. @@ -216,7 +229,9 @@ impl DirectorySyncClient { provider_settings.admin_email.as_ref(), ) { (Some(key), Some(email), Some(admin_email)) => { - debug!("Google directory has all the configuration needed, proceeding with creating the sync client"); + debug!( + "Google directory has all the configuration needed, proceeding with creating the sync client" + ); let client = google::GoogleDirectorySync::new(key, email, admin_email); debug!("Google directory sync client created"); Ok(Self::Google(client)) @@ -240,7 +255,9 @@ impl DirectorySyncClient { provider_settings.okta_private_jwk.as_ref(), provider_settings.okta_dirsync_client_id.as_ref(), ) { - debug!("Okta directory has all the configuration needed, proceeding with creating the sync client"); + debug!( + "Okta directory has all the configuration needed, proceeding with creating the sync client" + ); let client = okta::OktaDirectorySync::new(jwk, client_id, &provider_settings.base_url); debug!("Okta directory sync client created"); @@ -252,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( @@ -423,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?; @@ -435,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", @@ -493,15 +524,17 @@ async fn sync_all_users_groups( continue; } debug!( - "Removing user {} from group {} as they are not a member of it in the directory", - user.email, current_group.name - ); + "Removing user {} from group {} as they are not a member of it in the directory", + user.email, current_group.name + ); user.remove_from_group(&mut *transaction, current_group) .await?; admin_count -= 1; } else { - debug!("Removing user {} from group {} as they are not a member of it in the directory", - user.email, current_group.name); + debug!( + "Removing user {} from group {} as they are not a member of it in the directory", + user.email, current_group.name + ); user.remove_from_group(&mut *transaction, current_group) .await?; } @@ -523,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(()) } @@ -545,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)?; @@ -754,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"); @@ -805,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) => { @@ -885,747 +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::{ - config::DefGuardConfig, - db::{ - models::{device::DeviceType, settings::initialize_current_settings}, - setup_pool, Device, Session, SessionState, Settings, WireguardNetwork, - }, - enterprise::db::models::openid_provider::DirectorySyncTarget, - SERVER_CONFIG, - }; - - 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![], - false, - 32, - 32, - false, - false, - ) - .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 cc6dcf0022..bbc168fe16 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/okta.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/okta.rs @@ -1,13 +1,13 @@ use std::str::FromStr; use chrono::{DateTime, TimeDelta, Utc}; -use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use parse_link_header::parse_with_rel; use tokio::time::sleep; use super::{ - parse_response, DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, - REQUEST_PAGINATION_SLOWDOWN, + DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser, REQUEST_PAGINATION_SLOWDOWN, + parse_response, }; use crate::enterprise::directory_sync::make_get_request; @@ -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/firewall.rs b/crates/defguard_core/src/enterprise/firewall/mod.rs similarity index 89% rename from crates/defguard_core/src/enterprise/firewall.rs rename to crates/defguard_core/src/enterprise/firewall/mod.rs index 90f2f6bbdc..4611be8acf 100644 --- a/crates/defguard_core/src/enterprise/firewall.rs +++ b/crates/defguard_core/src/enterprise/firewall/mod.rs @@ -4,7 +4,7 @@ use std::{ }; use ipnetwork::IpNetwork; -use sqlx::{query_as, query_scalar, Error as SqlxError, PgConnection}; +use sqlx::{Error as SqlxError, PgConnection, query_as, query_scalar}; use super::{ db::models::acl::{ @@ -14,11 +14,15 @@ use super::{ utils::merge_ranges, }; use crate::{ - db::{models::error::ModelError, Device, Id, User, WireguardNetwork}, - enterprise::{db::models::acl::AliasKind, is_enterprise_enabled}, + db::{Device, Id, User, WireguardNetwork, models::error::ModelError}, + enterprise::{ + db::models::{acl::AliasKind, snat::UserSnatBinding}, + is_enterprise_enabled, + }, grpc::proto::enterprise::firewall::{ - ip_address::Address, port::Port as PortInner, FirewallConfig, FirewallPolicy, FirewallRule, - IpAddress, IpRange, IpVersion, Port, PortRange as PortRangeProto, + FirewallConfig, FirewallPolicy, FirewallRule, IpAddress, IpRange, IpVersion, Port, + PortRange as PortRangeProto, SnatBinding as SnatBindingProto, ip_address::Address, + port::Port as PortInner, }, }; @@ -64,10 +68,11 @@ pub async fn generate_firewall_rules_from_acls( // get relevant users for determining source IPs let users = get_source_users(allowed_users, &denied_users); + // prepare a list of user IDs + let user_ids: Vec = users.iter().map(|user| user.id).collect(); // get network IPs for devices belonging to those users - let user_device_ips = get_user_device_ips(&users, location_id, &mut *conn).await?; - + let user_device_ips = get_user_device_ips(&user_ids, location_id, &mut *conn).await?; // separate IPv4 and IPv6 user-device addresses let user_device_ips = user_device_ips .iter() @@ -323,13 +328,10 @@ fn get_source_users(allowed_users: Vec>, denied_users: &[User]) -> /// Fetches all IPs of devices belonging to specified users within a given location's VPN subnet. /// We specifically only fetch user devices since network devices are handled separately. async fn get_user_device_ips<'e, E: sqlx::PgExecutor<'e>>( - users: &[User], + user_ids: &[Id], location_id: Id, executor: E, ) -> Result>, SqlxError> { - // prepare a list of user IDs - let user_ids: Vec = users.iter().map(|user| user.id).collect(); - // fetch network IPs query_scalar!( "SELECT wireguard_ips \"wireguard_ips: Vec\" \ @@ -779,6 +781,87 @@ fn merge_port_ranges(port_ranges: Vec) -> Vec { .collect() } +/// Converts user SNAT bindings into SNAT config to be sent to a gateway as part of `FirewallConfig`. +/// +/// To generate the final SNAT binding we need to find all user devices +/// and get their IPs to generate a list of source addresses for a firewall rule. +async fn generate_user_snat_bindings_for_location( + location_id: Id, + conn: &mut PgConnection, +) -> Result, SqlxError> { + debug!("Generating SNAT bindings for location {location_id}"); + + let user_snat_bindings = UserSnatBinding::all_for_location(&mut *conn, location_id).await?; + + // check if there are any bindings configured for this location + if user_snat_bindings.is_empty() { + debug!("No user SNAT bindings configured for location {location_id}"); + return Ok(Vec::new()); + } + + // initialize output list + let mut bindings = Vec::new(); + + // process each user SNAT binding + for user_binding in user_snat_bindings { + let user_id = user_binding.user_id; + + debug!( + "Processing SNAT binding for user {user_id} with public IP {}", + user_binding.public_ip + ); + + // determine IP protocol version based on public IP + let is_ipv4 = user_binding.public_ip.is_ipv4(); + + // fetch all device IPs for this specific user in the location + let user_device_ips = get_user_device_ips(&[user_id], location_id, &mut *conn).await?; + + // separate IPv4 and IPv6 user-device addresses + let (user_device_ips_v4, user_device_ips_v6) = user_device_ips + .iter() + .flatten() + .partition(|ip| ip.is_ipv4()); + + // convert device IPs into source addresses for a firewall rule + let source_addrs = if is_ipv4 { + get_source_addrs(user_device_ips_v4, Vec::new(), IpVersion::Ipv4) + } else { + get_source_addrs(user_device_ips_v6, Vec::new(), IpVersion::Ipv6) + }; + + if source_addrs.is_empty() { + debug!( + "No compatible device IPs found for user {user_id} in location {location_id} with public IP {}, skipping SNAT binding", + user_binding.public_ip + ); + continue; + } + + // create the SNAT binding proto + let snat_binding = SnatBindingProto { + id: user_binding.id, + source_addrs, + public_ip: user_binding.public_ip.to_string(), + comment: Some(format!("User {user_id} SNAT binding {}", user_binding.id)), + }; + + debug!( + "Created SNAT binding for user {user_id} in location {location_id}: {snat_binding:?}", + ); + + // add to output list + bindings.push(snat_binding); + } + + debug!( + "Generated {} SNAT bindings for location {location_id}", + bindings.len(), + ); + + Ok(bindings) +} + impl WireguardNetwork { /// Fetches all active ACL rules for a given location. /// Filters out rules which are disabled, expired or have not been deployed yet. @@ -846,9 +929,11 @@ impl WireguardNetwork { }; let firewall_rules = generate_firewall_rules_from_acls(self.id, location_acls, &mut *conn).await?; + let snat_bindings = generate_user_snat_bindings_for_location(self.id, &mut *conn).await?; let firewall_config = FirewallConfig { default_policy: default_policy.into(), rules: firewall_rules, + snat_bindings, }; debug!("Firewall config generated for location {self}: {firewall_config:?}"); diff --git a/crates/defguard_core/src/enterprise/firewall/tests.rs b/crates/defguard_core/src/enterprise/firewall/tests.rs index 5b839d3368..d286e4a1c2 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests.rs @@ -2,10 +2,11 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use chrono::{DateTime, NaiveDateTime}; use ipnetwork::{IpNetwork, Ipv6Network}; -use rand::{thread_rng, Rng}; +use rand::{Rng, thread_rng}; use sqlx::{ + PgPool, postgres::{PgConnectOptions, PgPoolOptions}, - query, PgPool, + query, }; use super::{ @@ -14,8 +15,9 @@ use super::{ }; use crate::{ db::{ + Device, Group, Id, NoId, User, WireguardNetwork, models::device::{DeviceType, WireguardNetworkDevice}, - setup_pool, Device, Group, Id, NoId, User, WireguardNetwork, + setup_pool, }, enterprise::{ db::models::acl::{ @@ -25,8 +27,8 @@ use crate::{ firewall::{get_source_addrs, get_source_network_devices}, }, grpc::proto::enterprise::firewall::{ - ip_address::Address, port::Port as PortInner, FirewallPolicy, IpAddress, IpRange, - IpVersion, Port, PortRange as PortRangeProto, Protocol, + FirewallPolicy, IpAddress, IpRange, IpVersion, Port, PortRange as PortRangeProto, Protocol, + ip_address::Address, port::Port as PortInner, }, }; @@ -42,13 +44,13 @@ impl Default for AclRuleDestinationRange { } fn random_user_with_id(rng: &mut R, id: Id) -> User { - let mut user: User = rng.gen(); + let mut user: User = rng.r#gen(); user.id = id; user } fn random_network_device_with_id(rng: &mut R, id: Id) -> Device { - let mut device: Device = rng.gen(); + let mut device: Device = rng.r#gen(); device.id = id; device.device_type = DeviceType::Network; device @@ -935,15 +937,15 @@ async fn test_generate_firewall_rules_ipv4(_: PgPoolOptions, options: PgConnectO let mut location = location.save(&pool).await.unwrap(); // Setup test users and their devices - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); - let user_3: User = rng.gen(); + let user_3: User = rng.r#gen(); let user_3 = user_3.save(&pool).await.unwrap(); - let user_4: User = rng.gen(); + let user_4: User = rng.r#gen(); let user_4 = user_4.save(&pool).await.unwrap(); - let user_5: User = rng.gen(); + let user_5: User = rng.r#gen(); let user_5 = user_5.save(&pool).await.unwrap(); for user in [&user_1, &user_2, &user_3, &user_4, &user_5] { @@ -1352,15 +1354,15 @@ async fn test_generate_firewall_rules_ipv6(_: PgPoolOptions, options: PgConnectO let mut location = location.save(&pool).await.unwrap(); // Setup test users and their devices - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); - let user_3: User = rng.gen(); + let user_3: User = rng.r#gen(); let user_3 = user_3.save(&pool).await.unwrap(); - let user_4: User = rng.gen(); + let user_4: User = rng.r#gen(); let user_4 = user_4.save(&pool).await.unwrap(); - let user_5: User = rng.gen(); + let user_5: User = rng.r#gen(); let user_5 = user_5.save(&pool).await.unwrap(); for user in [&user_1, &user_2, &user_3, &user_4, &user_5] { @@ -1801,15 +1803,15 @@ async fn test_generate_firewall_rules_ipv4_and_ipv6(_: PgPoolOptions, options: P let mut location = location.save(&pool).await.unwrap(); // Setup test users and their devices - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); - let user_3: User = rng.gen(); + let user_3: User = rng.r#gen(); let user_3 = user_3.save(&pool).await.unwrap(); - let user_4: User = rng.gen(); + let user_4: User = rng.r#gen(); let user_4 = user_4.save(&pool).await.unwrap(); - let user_5: User = rng.gen(); + let user_5: User = rng.r#gen(); let user_5 = user_5.save(&pool).await.unwrap(); for user in [&user_1, &user_2, &user_3, &user_4, &user_5] { @@ -3080,9 +3082,9 @@ async fn test_acl_rules_all_locations_ipv4(_: PgPoolOptions, options: PgConnectO }; let location_2 = location_2.save(&pool).await.unwrap(); // Setup some test users and their devices - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); for user in [&user_1, &user_2] { @@ -3234,9 +3236,9 @@ async fn test_acl_rules_all_locations_ipv6(_: PgPoolOptions, options: PgConnectO let location_2 = location_2.save(&pool).await.unwrap(); // Setup some test users and their devices - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); for user in [&user_1, &user_2] { @@ -3401,9 +3403,9 @@ async fn test_acl_rules_all_locations_ipv4_and_ipv6(_: PgPoolOptions, options: P }; let location_2 = location_2.save(&pool).await.unwrap(); // Setup some test users and their devices - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); for user in [&user_1, &user_2] { @@ -3565,9 +3567,9 @@ async fn test_alias_kinds(_: PgPoolOptions, options: PgConnectOptions) { .unwrap(); // Setup some test users and their devices - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); for user in [&user_1, &user_2] { @@ -3758,9 +3760,9 @@ async fn test_destination_alias_only_acl(_: PgPoolOptions, options: PgConnectOpt .unwrap(); // Setup some test users and their devices - let user_1: User = rng.gen(); + let user_1: User = rng.r#gen(); let user_1 = user_1.save(&pool).await.unwrap(); - let user_2: User = rng.gen(); + let user_2: User = rng.r#gen(); let user_2 = user_2.save(&pool).await.unwrap(); for user in [&user_1, &user_2] { diff --git a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs new file mode 100644 index 0000000000..069d1fd86d --- /dev/null +++ b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs @@ -0,0 +1,156 @@ +use openidconnect::{AuthorizationCode, Nonce}; +use reqwest::Url; +use tonic::Status; + +use crate::{ + enterprise::{ + handlers::openid_login::{extract_state_data, user_from_claims}, + is_enterprise_enabled, + }, + events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, + grpc::{ + client_mfa::{ClientLoginSession, ClientMfaServer}, + proto::proxy::{ClientMfaOidcAuthenticateRequest, DeviceInfo, MfaMethod}, + utils::parse_client_info, + }, +}; + +impl ClientMfaServer { + #[instrument(skip_all)] + pub async fn auth_mfa_session_with_oidc( + &mut self, + request: ClientMfaOidcAuthenticateRequest, + info: Option, + ) -> Result<(), Status> { + debug!("Received OIDC MFA authentication request: {request:?}"); + if !is_enterprise_enabled() { + error!("OIDC MFA method requires enterprise feature to be enabled"); + return Err(Status::invalid_argument("OIDC MFA method is not supported")); + } + + let token = extract_state_data(&request.state).ok_or_else(|| { + error!( + "Failed to extract state data from state: {:?}", + request.state + ); + Status::invalid_argument("invalid state data") + })?; + if token.is_empty() { + debug!("Empty token provided in request"); + return Err(Status::invalid_argument("empty token provided")); + } + let pubkey = Self::parse_token(&token)?; + + // fetch login session + let Some(session) = self.sessions.get(&pubkey).cloned() else { + debug!("Client login session not found"); + return Err(Status::invalid_argument("login session not found")); + }; + let ClientLoginSession { + method, + device, + location, + user, + openid_auth_completed, + biometric_challenge: _, + } = session; + + if openid_auth_completed { + debug!("Client login session already completed"); + return Err(Status::invalid_argument("login session already completed")); + } + + if method != MfaMethod::Oidc { + debug!("Invalid MFA method for OIDC authentication: {method:?}"); + self.sessions.remove(&pubkey); + return Err(Status::invalid_argument("invalid MFA method")); + } + + let (ip, _user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let context = BidiRequestContext::new( + user.id, + user.username.clone(), + ip, + format!("{} (ID {})", device.name, device.id), + ); + + let code = AuthorizationCode::new(request.code.clone()); + let url = match Url::parse(&request.callback_url).map_err(|err| { + error!("Invalid redirect URL provided: {err:?}"); + Status::invalid_argument("invalid redirect URL") + }) { + Ok(url) => url, + Err(status) => { + self.sessions.remove(&pubkey); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + location: location.clone(), + device: device.clone(), + method, + message: "provided invalid redirect URL".to_string(), + }, + )), + })?; + return Err(status); + } + }; + + match user_from_claims(&self.pool, Nonce::new(request.nonce.clone()), code, url).await { + Ok(claims_user) => { + // if thats not our user, prevent login + if claims_user.id != user.id { + info!("User {claims_user} tried to use OIDC MFA for another user: {user}"); + self.sessions.remove(&pubkey); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + location: location.clone(), + device: device.clone(), + method, + message: format!("user {claims_user} tried to use OIDC MFA for another user: {user}") + }, + )), + })?; + return Err(Status::unauthenticated("unauthorized")); + } + info!( + "OIDC MFA authentication completed successfully for user: {}", + user.username + ); + } + Err(err) => { + info!("Failed to verify OIDC code: {err:?}"); + self.sessions.remove(&pubkey); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + location: location.clone(), + device: device.clone(), + method, + message: format!("failed to verify OIDC code: {err:?}"), + }, + )), + })?; + return Err(Status::unauthenticated("unauthorized")); + } + } + + self.sessions.insert( + pubkey.clone(), + ClientLoginSession { + method, + device: device.clone(), + location: location.clone(), + user: user.clone(), + openid_auth_completed: true, + biometric_challenge: None, + }, + ); + + Ok(()) + } +} diff --git a/crates/defguard_core/src/enterprise/grpc/mod.rs b/crates/defguard_core/src/enterprise/grpc/mod.rs index 505916a0a5..cc68fd70db 100644 --- a/crates/defguard_core/src/enterprise/grpc/mod.rs +++ b/crates/defguard_core/src/enterprise/grpc/mod.rs @@ -1 +1,2 @@ +pub mod desktop_client_mfa; pub mod polling; diff --git a/crates/defguard_core/src/enterprise/grpc/polling.rs b/crates/defguard_core/src/enterprise/grpc/polling.rs index aad5037525..b7c7eacef7 100644 --- a/crates/defguard_core/src/enterprise/grpc/polling.rs +++ b/crates/defguard_core/src/enterprise/grpc/polling.rs @@ -2,7 +2,7 @@ use sqlx::PgPool; use tonic::Status; use crate::{ - db::{models::polling_token::PollingToken, Device, Id, User}, + db::{Device, Id, User, models::polling_token::PollingToken}, enterprise::is_enterprise_enabled, grpc::{ proto::proxy::{InstanceInfoRequest, InstanceInfoResponse}, diff --git a/crates/defguard_core/src/enterprise/handlers/acl.rs b/crates/defguard_core/src/enterprise/handlers/acl.rs index dd7ce14547..d2ce367206 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl.rs @@ -1,10 +1,10 @@ use axum::{ + Json, extract::{Path, State}, http::StatusCode, - Json, }; use chrono::NaiveDateTime; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use super::LicenseInfo; use crate::{ @@ -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/activity_log_stream.rs b/crates/defguard_core/src/enterprise/handlers/activity_log_stream.rs index 3887265dc0..feca06d5b1 100644 --- a/crates/defguard_core/src/enterprise/handlers/activity_log_stream.rs +++ b/crates/defguard_core/src/enterprise/handlers/activity_log_stream.rs @@ -1,6 +1,6 @@ use axum::{ - extract::{Path, State}, Json, + extract::{Path, State}, }; use reqwest::StatusCode; use serde_json::json; @@ -67,10 +67,7 @@ pub async fn create_activity_log_stream( info!("User {session_username} created activity log stream"); appstate.emit_event(ApiEvent { context, - event: ApiEventType::ActivityLogStreamCreated { - stream_id: stream.id, - stream_name: stream.name, - }, + event: Box::new(ApiEventType::ActivityLogStreamCreated { stream }), })?; debug!("ActivityLogStreamCreated api event sent"); Ok(ApiResponse { @@ -91,18 +88,23 @@ pub async fn modify_activity_log_stream( let session_username = &session.user.username; debug!("User {session_username} modifies activity log stream "); if let Some(mut stream) = ActivityLogStream::find_by_id(&appstate.pool, id).await? { + // store stream before modifications + let before = stream.clone(); //validate config let _ = ActivityLogStreamConfig::from_serde_value(&data.stream_type, &data.stream_config)?; stream.name = data.name; stream.config = data.stream_config; stream.save(&appstate.pool).await?; - info!("User {session_username} modified activity log stream"); + info!( + "User {session_username} modified activity log stream {}", + stream.name + ); appstate.emit_event(ApiEvent { context, - event: ApiEventType::ActivityLogStreamModified { - stream_id: stream.id, - stream_name: stream.name, - }, + event: Box::new(ApiEventType::ActivityLogStreamModified { + before, + after: stream, + }), })?; debug!("ActivityLogStreamModified api event sent"); return Ok(ApiResponse::default()); @@ -123,15 +125,10 @@ pub async fn delete_activity_log_stream( let session_username = &session.user.username; debug!("User {session_username} deleting Activity Log Stream ({id})"); if let Some(stream) = ActivityLogStream::find_by_id(&appstate.pool, id).await? { - let stream_id = stream.id; - let stream_name = stream.name.clone(); - stream.delete(&appstate.pool).await?; + stream.clone().delete(&appstate.pool).await?; appstate.emit_event(ApiEvent { context, - event: ApiEventType::ActivityLogStreamRemoved { - stream_id, - stream_name, - }, + event: Box::new(ApiEventType::ActivityLogStreamRemoved { stream }), })?; } else { return Err(crate::error::WebError::ObjectNotFound(format!( diff --git a/crates/defguard_core/src/enterprise/handlers/api_tokens.rs b/crates/defguard_core/src/enterprise/handlers/api_tokens.rs index 2f11c46ad4..d76a3488ea 100644 --- a/crates/defguard_core/src/enterprise/handlers/api_tokens.rs +++ b/crates/defguard_core/src/enterprise/handlers/api_tokens.rs @@ -1,7 +1,7 @@ use axum::{ + Json, extract::{Path, State}, http::StatusCode, - Json, }; use chrono::Utc; use serde_json::json; @@ -10,9 +10,11 @@ use super::LicenseInfo; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, + db::User, enterprise::db::models::api_tokens::{ApiToken, ApiTokenInfo}, error::WebError, - handlers::{user_for_admin_or_self, ApiResponse, ApiResult}, + events::{ApiEvent, ApiEventType, ApiRequestContext}, + handlers::{ApiResponse, ApiResult, user_for_admin_or_self}, random::gen_alphanumeric, }; @@ -28,6 +30,7 @@ pub async fn add_api_token( _admin: AdminRole, State(appstate): State, session: SessionInfo, + context: ApiRequestContext, Path(username): Path, Json(data): Json, ) -> ApiResult { @@ -53,7 +56,7 @@ pub async fn add_api_token( // all API tokens start with a `dg-` prefix let token_string = format!("dg-{}", gen_alphanumeric(API_TOKEN_LENGTH)); - ApiToken::new( + let token = ApiToken::new( user.id, Utc::now().naive_utc(), data.name.clone(), @@ -63,7 +66,12 @@ pub async fn add_api_token( .await?; info!("Added new API token {} for user {username}", data.name); - + if let Some(owner) = User::find_by_id(&appstate.pool, token.user_id).await? { + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::ApiTokenAdded { owner, token }), + })?; + } Ok(ApiResponse { json: json!({"token": token_string}), status: StatusCode::CREATED, @@ -82,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 { @@ -96,6 +104,7 @@ pub async fn delete_api_token( _admin: AdminRole, State(appstate): State, session: SessionInfo, + context: ApiRequestContext, Path((username, token_id)): Path<(String, i64)>, ) -> ApiResult { debug!("Removing API token {token_id} for user {username}"); @@ -104,7 +113,20 @@ pub async fn delete_api_token( if !session.is_admin && user.id != token.user_id { return Err(WebError::Forbidden(String::new())); } - token.delete(&appstate.pool).await?; + token.clone().delete(&appstate.pool).await?; + if let Some(owner) = User::find_by_id(&appstate.pool, token.user_id).await? { + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::ApiTokenRemoved { + owner, + token: token.clone(), + }), + })?; + } + info!( + "User {} removed API token {}({token_id}) for user {username}", + user.username, token.name + ); } else { error!("API token with id {token_id} not found"); return Err(WebError::BadRequest("Key not found".into())); @@ -126,16 +148,35 @@ pub async fn rename_api_token( _admin: AdminRole, State(appstate): State, session: SessionInfo, + context: ApiRequestContext, Path((username, token_id)): Path<(String, i64)>, Json(data): Json, ) -> ApiResult { + debug!("Renaming API token {token_id} for user {username}"); let user = user_for_admin_or_self(&appstate.pool, &session, &username).await?; if let Some(mut token) = ApiToken::find_by_id(&appstate.pool, token_id).await? { if !session.is_admin && user.id != token.user_id { return Err(WebError::Forbidden(String::new())); } + let old_name = token.name.clone(); token.name = data.name; + let new_name = token.name.clone(); token.save(&appstate.pool).await?; + if let Some(owner) = User::find_by_id(&appstate.pool, token.user_id).await? { + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::ApiTokenRenamed { + owner, + token: token.clone(), + old_name, + new_name, + }), + })?; + } + info!( + "User {} renamed API token {}({token_id}) for user {username}", + user.username, token.name + ); } else { error!("User {username} tried to rename non-existing API token with id {token_id}",); return Err(WebError::ObjectNotFound(String::new())); diff --git a/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs b/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs index 29cee9e854..437c2c3e7a 100644 --- a/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/handlers/enterprise_settings.rs @@ -1,4 +1,4 @@ -use axum::{extract::State, http::StatusCode, Json}; +use axum::{Json, extract::State, http::StatusCode}; use serde_json::json; use struct_patch::Patch; diff --git a/crates/defguard_core/src/enterprise/handlers/mod.rs b/crates/defguard_core/src/enterprise/handlers/mod.rs index 08a4da60bc..dac781bcfd 100644 --- a/crates/defguard_core/src/enterprise/handlers/mod.rs +++ b/crates/defguard_core/src/enterprise/handlers/mod.rs @@ -13,7 +13,7 @@ pub mod openid_providers; use axum::{ extract::{FromRef, FromRequestParts}, - http::{request::Parts, StatusCode}, + http::{StatusCode, request::Parts}, }; use super::{ diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index aa63aff4fc..0eadebbe0c 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -1,17 +1,18 @@ -use axum::{extract::State, http::StatusCode, Json}; +use axum::{Json, extract::State, http::StatusCode}; use axum_client_ip::InsecureClientIp; use axum_extra::{ + TypedHeader, extract::{ - cookie::{Cookie, SameSite}, CookieJar, PrivateCookieJar, + cookie::{Cookie, SameSite}, }, headers::UserAgent, - TypedHeader, }; +use base64::{Engine, prelude::BASE64_STANDARD}; use openidconnect::{ - core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims}, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, RedirectUrl, Scope, + core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims}, }; use reqwest::Url; use serde_json::json; @@ -21,11 +22,14 @@ 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::{ appstate::AppState, - db::{models::settings::OpenidUsernameHandling, Id, Settings, User}, + db::{Id, Settings, User, models::settings::OpenidUsernameHandling}, enterprise::{ db::models::openid_provider::OpenIdProvider, directory_sync::sync_user_groups_if_configured, ldap::utils::ldap_update_user_state, @@ -33,8 +37,8 @@ use crate::{ }, error::WebError, handlers::{ - auth::create_session, user::check_username, ApiResponse, AuthResponse, SESSION_COOKIE_NAME, - SIGN_IN_COOKIE_NAME, + ApiResponse, AuthResponse, SESSION_COOKIE_NAME, SIGN_IN_COOKIE_NAME, auth::create_session, + user::check_username, }, server_config, }; @@ -48,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(); @@ -85,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() @@ -101,17 +106,43 @@ async fn get_provider_metadata(url: &str) -> Result Ok(provider_metadata), - Err(err) => { - Err(WebError::Authorization(format!( - "Failed to discover provider metadata, make sure the provider's URL is correct: {url}. Error details: {err:?}", - ))) + Err(err) => Err(WebError::Authorization(format!( + "Failed to discover provider metadata, make sure the provider's URL is correct: {url}. Error details: {err:?}", + ))), + } +} + +/// Build a state with optional embedded data. Useful for passing additional information around the authentication flow. +pub(crate) fn build_state(state_data: Option) -> CsrfToken { + let csrf_token = CsrfToken::new_random(); + if let Some(data) = state_data { + let combined = format!("{}.{data}", csrf_token.secret()); + let encoded = BASE64_STANDARD.encode(combined); + CsrfToken::new(encoded) + } else { + csrf_token + } +} + +/// Extract the state data from the provided state. +pub(crate) fn extract_state_data(state: &str) -> Option { + let decoded = BASE64_STANDARD.decode(state).ok()?; + let decoded_str = String::from_utf8(decoded).ok()?; + let result = decoded_str.split_once('.'); + if let Some((part1, part2)) = result { + if part1.is_empty() { + None + } else { + Some(part2.to_string()) } + } else { + None } } @@ -160,7 +191,7 @@ pub(crate) async fn user_from_claims( )); }; let (client_id, core_client) = make_oidc_client(callback_url, &provider).await?; - let async_http_client = get_async_http_client().await?; + let async_http_client = get_async_http_client()?; // Exchange code for ID token. let token_response = match core_client .exchange_code(code) @@ -345,7 +376,7 @@ pub(crate) async fn user_from_claims( from the user info endpoint. Current values: given_name: {given_name:?}, family_name: {family_name:?}, phone: {phone:?}" ); - let async_http_client = get_async_http_client().await?; + let async_http_client = get_async_http_client()?; let retrieval_error = "Failed to retrieve given name and family name from provider's userinfo endpoint. \ Make sure you have configured your provider correctly and that you have granted the \ @@ -435,15 +466,24 @@ pub(crate) async fn get_auth_info( let (_client_id, client) = make_oidc_client(config.callback_url(), &provider).await?; // Generate the redirect URL and the values needed later for callback authenticity verification - let (authorize_url, csrf_state, nonce) = client + let mut authorize_url_builder = client .authorize_url( CoreAuthenticationFlow::AuthorizationCode, CsrfToken::new_random, Nonce::new_random, ) .add_scope(Scope::new("email".into())) - .add_scope(Scope::new("profile".into())) - .url(); + .add_scope(Scope::new("profile".into())); + + if SELECT_ACCOUNT_SUPPORTED_PROVIDERS + .iter() + .any(|&p| provider.name.eq_ignore_ascii_case(p)) + { + authorize_url_builder = + authorize_url_builder.add_prompt(openidconnect::core::CoreAuthPrompt::SelectAccount); + } + + let (authorize_url, csrf_state, nonce) = authorize_url_builder.url(); let cookie_domain = config .cookie_domain @@ -673,4 +713,54 @@ mod test { "averylongnameeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" ); } + + #[test] + fn test_state_build_and_extract() { + // without data + let token = build_state(None); + let decoded = BASE64_STANDARD.decode(token.secret()); + // not base64 encoded + assert!(decoded.is_err()); + assert!(!token.secret().is_empty()); + + // with data + let data = "somedata".to_string(); + let token = build_state(Some(data.clone())); + let decoded = BASE64_STANDARD.decode(token.secret()); + assert!(decoded.is_ok()); + let decoded_str = String::from_utf8(decoded.unwrap()).unwrap(); + let (csrf, state_data) = decoded_str.split_once('.').unwrap(); + assert!(!csrf.is_empty()); + assert_eq!(state_data, data); + + // valid + let data = "my_state_data".to_string(); + let token = build_state(Some(data.clone())); + let extracted = extract_state_data(token.secret()); + assert_eq!(extracted, Some(data)); + + // invalid base64 + let extracted = extract_state_data("not_base64!!"); + assert_eq!(extracted, None); + + // no dot + let encoded = BASE64_STANDARD.encode("no_dot_here"); + let extracted = extract_state_data(&encoded); + assert_eq!(extracted, None); + + // empty first part + let encoded = BASE64_STANDARD.encode(".somedata"); + let extracted = extract_state_data(&encoded); + assert_eq!(extracted, None); + + // empty second part + let encoded = BASE64_STANDARD.encode("csrf."); + let extracted = extract_state_data(&encoded); + assert_eq!(extracted, Some("".to_string())); + + // multiple dots + let encoded = BASE64_STANDARD.encode("csrf.data.with.dots"); + let extracted = extract_state_data(&encoded); + assert_eq!(extracted, Some("data.with.dots".to_string())); + } } diff --git a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs index 143a210e87..51d300116f 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs @@ -1,9 +1,9 @@ use axum::{ + Json, extract::{Path, State}, http::StatusCode, - Json, }; -use rsa::{pkcs8::DecodePrivateKey, RsaPrivateKey}; +use rsa::{RsaPrivateKey, pkcs8::DecodePrivateKey}; use serde_json::json; use super::LicenseInfo; @@ -11,12 +11,16 @@ use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, db::{ - models::settings::{update_current_settings, OpenidUsernameHandling}, - Settings, + Settings, WireguardNetwork, + models::{ + settings::{OpenidUsernameHandling, update_current_settings}, + wireguard::LocationMfaMode, + }, }, enterprise::{ db::models::openid_provider::OpenIdProvider, directory_sync::test_directory_sync_connection, }, + events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ApiResponse, ApiResult}, }; @@ -40,6 +44,7 @@ pub struct AddProviderData { pub okta_dirsync_client_id: Option, pub directory_sync_group_match: Option, pub username_handling: OpenidUsernameHandling, + pub jumpcloud_api_key: Option, } #[derive(Debug, Deserialize, Serialize)] @@ -51,9 +56,14 @@ pub async fn add_openid_provider( _license: LicenseInfo, _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Json(provider_data): Json, ) -> ApiResult { + debug!( + "User {} adding OpenID provider {}", + session.user.username, provider_data.name + ); let current_provider = OpenIdProvider::get_current(&appstate.pool).await?; // The key is sent from the frontend only when user explicitly changes it, as we never send it back. @@ -145,17 +155,20 @@ 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?; - debug!( - "User {} adding OpenID provider {}", - session.user.username, new_provider.name - ); info!( "User {} added OpenID client {}", session.user.username, new_provider.name ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::OpenIdProviderModified { + provider: new_provider, + }), + })?; Ok(ApiResponse { json: json!({}), @@ -169,7 +182,6 @@ pub async fn get_current_openid_provider( State(appstate): State, ) -> ApiResult { let settings = Settings::get_current_settings(); - let create_account = settings.openid_create_account; match OpenIdProvider::get_current(&appstate.pool).await? { Some(mut provider) => { // Get rid of it, it should stay on the backend only. @@ -178,7 +190,7 @@ pub async fn get_current_openid_provider( Ok(ApiResponse { json: json!({ "provider": json!(provider), - "settings": json!({ "create_account": create_account, "username_handling": settings.openid_username_handling}), + "settings": json!({ "create_account": settings.openid_create_account, "username_handling": settings.openid_username_handling }), }), status: StatusCode::OK, }) @@ -186,7 +198,7 @@ pub async fn get_current_openid_provider( None => Ok(ApiResponse { json: json!({ "provider": null, - "settings": json!({ "create_account": create_account }), + "settings": json!({ "create_account": settings.openid_create_account, "username_handling": settings.openid_username_handling }), }), status: StatusCode::NO_CONTENT, }), @@ -197,6 +209,7 @@ pub async fn delete_openid_provider( _license: LicenseInfo, _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(provider_data): Path, ) -> ApiResult { @@ -204,13 +217,32 @@ pub async fn delete_openid_provider( "User {} deleting OpenID provider {}", session.user.username, provider_data.name ); - let provider = OpenIdProvider::find_by_name(&appstate.pool, &provider_data.name).await?; + let mut transaction = appstate.pool.begin().await?; + let provider = OpenIdProvider::find_by_name(&mut *transaction, &provider_data.name).await?; if let Some(provider) = provider { - provider.delete(&appstate.pool).await?; + provider.clone().delete(&mut *transaction).await?; + // fetch all locations using external MFA + 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!( + "Falling back to internal MFA for {location} because exteral OIDC provider has been removed" + ); + location.location_mfa_mode = LocationMfaMode::Internal; + location.save(&mut *transaction).await?; + } + transaction.commit().await?; info!( "User {} deleted OpenID provider {}", - session.user.username, provider_data.name + session.user.username, provider.name ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::OpenIdProviderRemoved { provider }), + })?; Ok(ApiResponse { json: json!({}), status: StatusCode::OK, @@ -238,12 +270,13 @@ pub async fn modify_openid_provider( "User {} modifying OpenID provider {}", session.user.username, provider_data.name ); - let provider = OpenIdProvider::find_by_name(&appstate.pool, &provider_data.name).await?; + let mut transaction = appstate.pool.begin().await?; + let provider = OpenIdProvider::find_by_name(&mut *transaction, &provider_data.name).await?; if let Some(mut provider) = provider { provider.base_url = provider_data.base_url; provider.client_id = provider_data.client_id; provider.client_secret = provider_data.client_secret; - provider.save(&appstate.pool).await?; + provider.save(&mut *transaction).await?; info!( "User {} modified OpenID client {}", session.user.username, provider.name diff --git a/crates/defguard_core/src/enterprise/ldap/client.rs b/crates/defguard_core/src/enterprise/ldap/client.rs index 30f06f2e51..6e029ac291 100644 --- a/crates/defguard_core/src/enterprise/ldap/client.rs +++ b/crates/defguard_core/src/enterprise/ldap/client.rs @@ -5,8 +5,8 @@ use std::{ }; use ldap3::{ - adapters::PagedResults, drive, ldap_escape, LdapConnAsync, LdapConnSettings, Mod, Scope, - SearchEntry, + LdapConnAsync, LdapConnSettings, Mod, Scope, SearchEntry, adapters::PagedResults, drive, + ldap_escape, }; use super::error::LdapError; @@ -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 af92677f0a..a5b47dbb8e 100644 --- a/crates/defguard_core/src/enterprise/ldap/hash.rs +++ b/crates/defguard_core/src/enterprise/ldap/hash.rs @@ -1,9 +1,9 @@ use base64::Engine; use md4::Md4; -use rand_core::{OsRng, RngCore}; +use rand::{RngCore, rngs::OsRng}; use sha1::{ - digest::generic_array::{sequence::Concat, GenericArray}, Digest, Sha1, + digest::generic_array::{GenericArray, sequence::Concat}, }; use crate::hex::to_lower_hex; @@ -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 e049d11c0d..c4ab28da63 100644 --- a/crates/defguard_core/src/enterprise/ldap/mod.rs +++ b/crates/defguard_core/src/enterprise/ldap/mod.rs @@ -2,15 +2,15 @@ use std::{collections::HashSet, future::Future}; #[cfg(not(test))] use ldap3::Ldap; -use ldap3::{ldap_escape, Mod, SearchEntry}; +use ldap3::{Mod, SearchEntry, ldap_escape}; use model::UserObjectClass; use rand::Rng; use sqlx::PgPool; -use sync::{get_ldap_sync_status, is_ldap_desynced, set_ldap_sync_status, SyncStatus}; +use sync::{SyncStatus, get_ldap_sync_status, is_ldap_desynced, set_ldap_sync_status}; use self::error::LdapError; use crate::{ - db::{self, models::settings::update_current_settings, Id, Settings, User}, + db::{self, Id, Settings, User, models::settings::update_current_settings}, enterprise::{is_enterprise_enabled, ldap::model::extract_dn_path, limits::update_counts}, }; @@ -48,7 +48,9 @@ pub(crate) async fn do_ldap_sync(pool: &PgPool) -> Result<(), LdapError> { } if !is_enterprise_enabled() { - info!("Enterprise features are disabled, not performing LDAP sync and automatically disabling it"); + info!( + "Enterprise features are disabled, not performing LDAP sync and automatically disabling it" + ); settings.ldap_sync_enabled = false; update_current_settings(pool, settings).await?; return Err(LdapError::EnterpriseDisabled("LDAP sync".to_string())); @@ -58,7 +60,9 @@ pub(crate) async fn do_ldap_sync(pool: &PgPool) -> Result<(), LdapError> { info!("LDAP is considered to be desynced, doing a full sync"); } else { info!("Ldap is not considered to be desynced, doing an incremental sync"); - debug!("Because of incremental sync, LDAP authority will be selected to pull changes from LDAP"); + debug!( + "Because of incremental sync, LDAP authority will be selected to pull changes from LDAP" + ); } let mut ldap_connection = match LDAPConnection::create().await { @@ -111,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) @@ -232,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 } @@ -243,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()) } } @@ -385,7 +389,6 @@ impl LDAPConnection { ); self.sync_user_data(user, pool).await?; debug!("User {user} data synchronized"); - continue; } } @@ -626,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, @@ -692,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:?}." @@ -793,9 +796,9 @@ impl LDAPConnection { } if mods.is_empty() { - return Err(LdapError::MissingSettings( - format!("Can't set password as no password object class has been defined for the user {user}."), - )); + return Err(LdapError::MissingSettings(format!( + "Can't set password as no password object class has been defined for the user {user}." + ))); } self.modify(&user_dn, &user_dn, mods).await?; @@ -826,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])); @@ -838,7 +841,7 @@ impl LDAPConnection { group_name, member_dns .iter() - .map(|dn| dn.as_str()) + .map(String::as_str) .collect::>() .join(", ") ); @@ -899,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( @@ -916,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 7ee8918dbd..bfb3a20561 100644 --- a/crates/defguard_core/src/enterprise/ldap/model.rs +++ b/crates/defguard_core/src/enterprise/ldap/model.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use ldap3::{Mod, SearchEntry}; use sqlx::{Error as SqlxError, PgExecutor}; -use super::{error::LdapError, LDAPConfig}; +use super::{LDAPConfig, error::LdapError}; use crate::{ db::{Id, Settings, User}, handlers::user::check_username, @@ -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 eeba09f086..d7a795b6f5 100644 --- a/crates/defguard_core/src/enterprise/ldap/sync.rs +++ b/crates/defguard_core/src/enterprise/ldap/sync.rs @@ -56,9 +56,9 @@ use std::collections::{HashMap, HashSet}; use sqlx::{PgConnection, PgPool, Type}; -use super::{error::LdapError, LDAPConfig}; +use super::{LDAPConfig, error::LdapError}; use crate::{ - db::{models::settings::update_current_settings, Group, Id, Settings, User}, + db::{Group, Id, Settings, User, models::settings::update_current_settings}, hashset, }; @@ -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,43 +264,55 @@ 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!("Group {group:?} has members missing from Defguard, marking them for deletion in LDAP: {missing_from_defguard:?}"); + debug!( + "Group {group:?} has members missing from Defguard, marking them for deletion in LDAP: {missing_from_defguard:?}" + ); delete_ldap.insert(group.clone(), missing_from_defguard); } Authority::LDAP => { - debug!("Group {group:?} has members missing from Defguard, marking them for addition in Defguard: {missing_from_defguard:?}"); + debug!( + "Group {group:?} has members missing from Defguard, marking them for addition in Defguard: {missing_from_defguard:?}" + ); 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!("Group {group:?} has members missing from LDAP, marking them for addition to LDAP: {missing_from_ldap:?}"); + debug!( + "Group {group:?} has members missing from LDAP, marking them for addition to LDAP: {missing_from_ldap:?}" + ); add_ldap.insert(group.clone(), missing_from_ldap); } Authority::LDAP => { - debug!("Group {group:?} has members missing from LDAP, marking them for deletion in Defguard: {missing_from_ldap:?}"); + debug!( + "Group {group:?} has members missing from LDAP, marking them for deletion in Defguard: {missing_from_ldap:?}" + ); delete_defguard.insert(group.clone(), missing_from_ldap); } } - } else { - debug!("Group {group:?} has no members missing from LDAP"); } } else { match authority { Authority::Defguard => { - debug!("Group {group:?} is missing from LDAP, marking it for addition to LDAP along with all members due to Defguard authority"); + debug!( + "Group {group:?} is missing from LDAP, marking it for addition to LDAP along with all members due to Defguard authority" + ); add_ldap.insert(group.clone(), members); } Authority::LDAP => { - debug!("Group {group:?} is missing from LDAP, marking all its member for deletion from Defguard due to LDAP authority"); + debug!( + "Group {group:?} is missing from LDAP, marking all its member for deletion from Defguard due to LDAP authority" + ); delete_defguard.insert(group.clone(), members); } } @@ -308,11 +323,15 @@ pub(super) fn compute_group_sync_changes<'a>( if !defguard_memberships.contains_key(&group) { match authority { Authority::Defguard => { - debug!("Group {group:?} is missing from Defguard, marking all its member for deletion from LDAP due to Defguard authority"); + debug!( + "Group {group:?} is missing from Defguard, marking all its member for deletion from LDAP due to Defguard authority" + ); delete_ldap.insert(group, members); } Authority::LDAP => { - debug!("Group {group:?} is missing from Defguard, marking all its member for addition to Defguard due to LDAP authority"); + debug!( + "Group {group:?} is missing from Defguard, marking all its member for addition to Defguard due to LDAP authority" + ); add_defguard.insert(group, members); } } @@ -402,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)) @@ -417,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 @@ -435,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..." @@ -622,7 +642,10 @@ impl super::LDAPConnection { } } - debug!("The following groups were defined for sync: {:?}, only Defguard users belonging to these groups will be synced", sync_groups); + debug!( + "The following groups were defined for sync: {:?}, only Defguard users belonging to these groups will be synced", + sync_groups + ); let mut sync_group_members = HashSet::new(); for sync_group in &sync_groups { let members = sync_group.members(pool).await?; @@ -841,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?; } @@ -868,10 +891,9 @@ impl super::LDAPConnection { Ok(user) => all_users.push(user), Err(err) => { warn!( - "Failed to create user {} from LDAP entry, error: {}. The user will be skipped during sync", - username, err + "Failed to create user {username} from LDAP entry, error: {err}. The user will be skipped during sync" ); - debug!("Skipping user {} due to error: {}", username, err); + debug!("Skipping user {username} due to error: {err}"); } } } 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 895f047d3f..8d9d47fe4b 100644 --- a/crates/defguard_core/src/enterprise/ldap/tests.rs +++ b/crates/defguard_core/src/enterprise/ldap/tests.rs @@ -6,14 +6,15 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; use crate::{ db::{ - models::settings::{initialize_current_settings, update_current_settings, Settings}, - setup_pool, Group, User, + Group, User, + models::settings::{Settings, initialize_current_settings, update_current_settings}, + setup_pool, }, enterprise::ldap::{ model::extract_rdn_value, sync::{ - compute_group_sync_changes, compute_user_sync_changes, extract_intersecting_users, - Authority, + Authority, compute_group_sync_changes, compute_user_sync_changes, + extract_intersecting_users, }, test_client::LdapEvent, }, @@ -284,10 +285,12 @@ async fn test_update_users_state(_: PgPoolOptions, options: PgConnectOptions) { // Verify initial LDAP state matches expectations assert!(ldap_conn.user_exists(&inactive_user_in_ldap).await.unwrap()); assert!(ldap_conn.user_exists(&active_user_in_ldap).await.unwrap()); - assert!(!ldap_conn - .user_exists(&active_user_not_in_ldap) - .await - .unwrap()); + assert!( + !ldap_conn + .user_exists(&active_user_not_in_ldap) + .await + .unwrap() + ); // Trigger state synchronization - should add missing active user and remove inactive user ldap_conn @@ -435,19 +438,21 @@ async fn test_update_users_state(_: PgPoolOptions, options: PgConnectOptions) { .unwrap(); // Now removing the last member should delete both group and user - assert!(ldap_conn.test_client.events_match( - &[ - LdapEvent::ObjectDeleted { - dn: ldap_conn.config.group_dn(&group.name), - }, - LdapEvent::ObjectDeleted { - dn: ldap_conn - .config - .user_dn_from_user(&another_active_user_in_ldap), - }, - ], - true, - )); + assert!( + ldap_conn.test_client.events_match( + &[ + LdapEvent::ObjectDeleted { + dn: ldap_conn.config.group_dn(&group.name), + }, + LdapEvent::ObjectDeleted { + dn: ldap_conn + .config + .user_dn_from_user(&another_active_user_in_ldap), + }, + ], + true, + ) + ); } #[tokio::test] @@ -1520,17 +1525,19 @@ fn test_extract_intersecting_users_with_matches(_: PgPoolOptions, options: PgCon fn test_extract_intersecting_users_no_matches(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let mut defguard_users = vec![User::new( - "user1", - Some("password"), - "Last1", - "First1", - "user1@example.com", - None, - ) - .save(&pool) - .await - .unwrap()]; + let mut defguard_users = vec![ + User::new( + "user1", + Some("password"), + "Last1", + "First1", + "user1@example.com", + None, + ) + .save(&pool) + .await + .unwrap(), + ]; let mut ldap_users = vec![User::new( "user2", @@ -2839,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 50151578ec..d952e8e22f 100644 --- a/crates/defguard_core/src/enterprise/ldap/utils.rs +++ b/crates/defguard_core/src/enterprise/ldap/utils.rs @@ -1,12 +1,11 @@ -//! -//! 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}; use sqlx::PgPool; -use super::{error::LdapError, LDAPConnection}; +use super::{LDAPConnection, error::LdapError}; use crate::{ db::{Group, Id, User}, enterprise::ldap::with_ldap_status, @@ -26,19 +25,22 @@ 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"); + debug!( + "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?; defguard_user @@ -56,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; } @@ -78,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?; @@ -88,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!( @@ -113,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 @@ -149,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 @@ -178,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. @@ -192,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; } @@ -213,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, @@ -221,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 { @@ -249,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 7f9e96c639..420bdac777 100644 --- a/crates/defguard_core/src/enterprise/license.rs +++ b/crates/defguard_core/src/enterprise/license.rs @@ -4,18 +4,22 @@ use anyhow::Result; use base64::prelude::*; use chrono::{DateTime, TimeDelta, Utc}; use humantime::format_duration; -use pgp::{types::PublicKeyTrait, Deserializable, SignedPublicKey, StandaloneSignature}; +use pgp::{ + composed::{Deserializable, SignedPublicKey, StandaloneSignature}, + types::{KeyDetails, PublicKeyTrait}, +}; use prost::Message; -use sqlx::{error::Error as SqlxError, PgPool}; +use sqlx::{PgPool, error::Error as SqlxError}; use thiserror::Error; use tokio::time::sleep; use super::limits::Counts; use crate::{ - db::{models::settings::update_current_settings, Settings}, + VERSION, + db::{Settings, models::settings::update_current_settings}, global_value, grpc::proto::enterprise::license::{LicenseKey, LicenseLimits, LicenseMetadata}, - server_config, VERSION, + server_config, }; const LICENSE_SERVER_URL: &str = "https://pkgs.defguard.net/api/license/renew"; @@ -179,7 +183,9 @@ pub enum LicenseError { DbError(#[from] SqlxError), #[error("License decoding error: {0}")] DecodeError(String), - #[error("License is expired and has reached its maximum overdue time, please contact salesdefguard.net")] + #[error( + "License is expired and has reached its maximum overdue time, please contact salesdefguard.net" + )] LicenseExpired, #[error("License not found")] LicenseNotFound, @@ -202,6 +208,7 @@ pub struct License { pub subscription: bool, pub valid_until: Option>, pub limits: Option, + pub version_date_limit: Option>, } impl License { @@ -211,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, } } @@ -292,23 +301,35 @@ 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() { if license.is_max_overdue() { - warn!("The provided license has expired and reached its maximum overdue time, please contact salesdefguard.net"); + warn!( + "The provided license has expired and reached its maximum overdue time, please contact salesdefguard.net" + ); } else { - warn!("The provided license is about to expire and requires a renewal. An automatic renewal process will attempt to renew the license soon. Alternatively, automatic renewal attempt will be also performed at the next defguard start."); + warn!( + "The provided license is about to expire and requires a renewal. An automatic renewal process will attempt to renew the license soon. Alternatively, automatic renewal attempt will be also performed at the next defguard start." + ); } } if !license.subscription && license.is_expired() { - warn!("The provided license is not a subscription and has expired, please contact salesdefguard.net"); + warn!( + "The provided license is not a subscription and has expired, please contact salesdefguard.net" + ); } Ok(license) @@ -348,7 +369,9 @@ impl License { Ok(new_key) => { let new_license = License::from_base64(&new_key)?; save_license_key(pool, &new_key).await?; - info!("Successfully renewed and loaded the license, new license key saved to the database"); + info!( + "Successfully renewed and loaded the license, new license key saved to the database" + ); Ok(Some(new_license)) } Err(err) => { @@ -575,11 +598,15 @@ pub async fn run_periodic_license_check(pool: &PgPool) -> Result<(), LicenseErro // want to try to renew the license anymore if license.is_max_overdue() { check_period = *config.check_period; - warn!("Your license has expired and reached its maximum overdue date, please contact sales at salesdefguard.net"); + warn!( + "Your license has expired and reached its maximum overdue date, please contact sales at salesdefguard.net" + ); debug!("Changing check period to {}", format_duration(check_period)); false } else { - debug!("License requires renewal, as it is about to expire and is not past the maximum overdue time"); + debug!( + "License requires renewal, as it is about to expire and is not past the maximum overdue time" + ); true } } else { @@ -610,7 +637,10 @@ pub async fn run_periodic_license_check(pool: &PgPool) -> Result<(), LicenseErro info!("Successfully renewed the license"); } Err(err) => { - error!("Couldn't save the newly fetched license key to the database, error: {}", err); + error!( + "Couldn't save the newly fetched license key to the database, error: {}", + err + ); } }, Err(err) => { @@ -714,6 +744,7 @@ mod test { false, Some(Utc::now() - TimeDelta::days(1)), None, + None, ); assert!(validate_license(Some(&license), &counts).is_err()); @@ -723,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 @@ -736,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()); @@ -745,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( @@ -759,7 +793,9 @@ mod test { users: 1, devices: 1, locations: 1, + network_devices: Some(1), }), + None, ); assert!(validate_license(Some(&license), &counts).is_err()); @@ -772,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 b7ef4f0212..51a006a682 100644 --- a/crates/defguard_core/src/enterprise/limits.rs +++ b/crates/defguard_core/src/enterprise/limits.rs @@ -1,20 +1,22 @@ -use sqlx::{error::Error as SqlxError, query, PgPool}; +use sqlx::{PgPool, error::Error as SqlxError, query}; +use super::license::License; #[cfg(test)] use super::license::get_cached_license; -use super::license::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 } } @@ -170,16 +207,65 @@ mod test { use super::*; use crate::{ - enterprise::license::{set_cached_license, License}, + enterprise::license::{License, set_cached_license}, 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/mod.rs b/crates/defguard_core/src/enterprise/mod.rs index 2b7c276ff1..679296908e 100644 --- a/crates/defguard_core/src/enterprise/mod.rs +++ b/crates/defguard_core/src/enterprise/mod.rs @@ -7,6 +7,7 @@ pub mod handlers; pub mod ldap; pub mod license; pub mod limits; +pub mod snat; mod utils; use license::{get_cached_license, validate_license}; 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/error.rs b/crates/defguard_core/src/enterprise/snat/error.rs new file mode 100644 index 0000000000..23dec61dc4 --- /dev/null +++ b/crates/defguard_core/src/enterprise/snat/error.rs @@ -0,0 +1,37 @@ +use thiserror::Error; + +use crate::error::WebError; + +#[derive(Debug, Error)] +pub enum UserSnatBindingError { + #[error("Binding not found")] + BindingNotFound, + #[error("Binding already exists")] + BindingAlreadyExists, + #[error("Database error")] + DbError { source: sqlx::Error }, +} + +impl From for UserSnatBindingError { + fn from(value: sqlx::Error) -> Self { + match value { + sqlx::Error::RowNotFound => Self::BindingNotFound, + sqlx::Error::Database(err) if err.constraint() == Some("user_location") => { + Self::BindingAlreadyExists + } + _ => Self::DbError { source: value }, + } + } +} + +impl From for WebError { + fn from(value: UserSnatBindingError) -> Self { + match value { + UserSnatBindingError::BindingNotFound => WebError::ObjectNotFound(value.to_string()), + UserSnatBindingError::BindingAlreadyExists => { + WebError::ObjectAlreadyExists(value.to_string()) + } + UserSnatBindingError::DbError { source } => WebError::DbError(source.to_string()), + } + } +} diff --git a/crates/defguard_core/src/enterprise/snat/handlers.rs b/crates/defguard_core/src/enterprise/snat/handlers.rs new file mode 100644 index 0000000000..3b9e71adbf --- /dev/null +++ b/crates/defguard_core/src/enterprise/snat/handlers.rs @@ -0,0 +1,355 @@ +use std::net::IpAddr; + +use axum::{ + Json, + extract::{Path, State}, +}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use utoipa::ToSchema; + +use crate::{ + appstate::AppState, + auth::{AdminRole, SessionInfo}, + db::{GatewayEvent, Id, User, WireguardNetwork}, + enterprise::{ + db::models::snat::UserSnatBinding, handlers::LicenseInfo, snat::error::UserSnatBindingError, + }, + error::WebError, + events::{ApiEvent, ApiEventType, ApiRequestContext}, + handlers::{ApiResponse, ApiResult}, +}; + +/// 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", + tag = "SNAT", + params( + ("location_id" = Id, Path, description = "WireGuard location ID") + ), + responses( + (status = 200, description = "List of SNAT bindings", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin role required"), + (status = 404, description = "Not found - location does not exist"), + (status = 500, description = "Internal server error") + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn list_snat_bindings( + _license: LicenseInfo, + _admin_role: AdminRole, + session: SessionInfo, + Path(location_id): Path, + State(appstate): State, +) -> ApiResult { + let current_user = session.user.username; + // + // check if target location exists + let location = WireguardNetwork::find_by_id(&appstate.pool, location_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; + + debug!("User {current_user} listing SNAT bindings for WireGuard location {location}"); + + let bindings = UserSnatBinding::all_for_location(&appstate.pool, location.id).await?; + + Ok(ApiResponse { + json: json!(bindings), + status: StatusCode::OK, + }) +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct NewUserSnatBinding { + /// User ID to bind to the public IP + pub user_id: Id, + /// Public IP address for SNAT + #[schema(value_type = String)] + pub public_ip: IpAddr, +} + +/// 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", + tag = "SNAT", + params( + ("location_id" = Id, Path, description = "WireGuard location ID") + ), + request_body = NewUserSnatBinding, + responses( + (status = 201, description = "SNAT binding created successfully", body = UserSnatBinding), + (status = 400, description = "Bad request - Invalid input data"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin role required"), + (status = 404, description = "Not found - location or user does not exist"), + (status = 409, description = "Conflict - Binding already exists"), + (status = 500, description = "Internal server error") + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn create_snat_binding( + _license: LicenseInfo, + _admin_role: AdminRole, + session: SessionInfo, + context: ApiRequestContext, + Path(location_id): Path, + State(appstate): State, + Json(data): Json, +) -> ApiResult { + let current_user = session.user.username; + + // check if target location & user exist + let location = WireguardNetwork::find_by_id(&appstate.pool, location_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; + let snat_user = User::find_by_id(&appstate.pool, data.user_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("User {} not found", data.user_id)))?; + + debug!( + "User {current_user} creating new SNAT binding for user {snat_user} in WireGuard location {location} with {data:?}" + ); + + let snat_binding = UserSnatBinding::new(data.user_id, location.id, data.public_ip); + + let binding = snat_binding + .save(&appstate.pool) + .await + .map_err(UserSnatBindingError::from)?; + + // emit event + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::UserSnatBindingAdded { + user: snat_user, + location: location.clone(), + binding: binding.clone(), + }), + })?; + + // trigger firewall config update on relevant gateways + let mut conn = appstate.pool.acquire().await?; + if let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, location.id).await? { + if let Some(firewall_config) = location.try_get_firewall_config(&mut conn).await? { + debug!( + "Sending firewall config update for location {location} affected by adding new SNAT binding" + ); + appstate.send_wireguard_event(GatewayEvent::FirewallConfigChanged( + location.id, + firewall_config, + )); + } + } + + Ok(ApiResponse { + json: json!(binding), + status: StatusCode::CREATED, + }) +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct EditUserSnatBinding { + /// New public IP address for SNAT + #[schema(value_type = String)] + pub public_ip: IpAddr, +} + +/// 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}", + tag = "SNAT", + params( + ("location_id" = Id, Path, description = "WireGuard location ID"), + ("user_id" = Id, Path, description = "User ID") + ), + request_body = EditUserSnatBinding, + responses( + (status = 200, description = "SNAT binding updated successfully", body = UserSnatBinding), + (status = 400, description = "Bad request - Invalid input data"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin role required"), + (status = 404, description = "Not found - SNAT binding does not exist"), + (status = 500, description = "Internal server error") + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn modify_snat_binding( + _license: LicenseInfo, + _admin_role: AdminRole, + session: SessionInfo, + context: ApiRequestContext, + Path((location_id, user_id)): Path<(Id, Id)>, + State(appstate): State, + Json(data): Json, +) -> ApiResult { + let current_user = session.user.username; + + // fetch relevant location & user + let location = WireguardNetwork::find_by_id(&appstate.pool, location_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; + let snat_user = User::find_by_id(&appstate.pool, user_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("User {user_id} not found")))?; + + debug!( + "User {current_user} updating SNAT binding for user {snat_user} and WireGuard location {location} with {data:?}", + ); + + // fetch existing binding + let mut snat_binding = + UserSnatBinding::find_binding(&appstate.pool, location_id, user_id).await?; + + // clone state before modifications + let before = snat_binding.clone(); + + // update public IP + snat_binding.update_ip(data.public_ip); + snat_binding.save(&appstate.pool).await?; + + // emit event + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::UserSnatBindingModified { + user: snat_user, + location: location.clone(), + before, + after: snat_binding.clone(), + }), + })?; + + // trigger firewall config update on relevant gateways + let mut conn = appstate.pool.acquire().await?; + if let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, location_id).await? { + if let Some(firewall_config) = location.try_get_firewall_config(&mut conn).await? { + debug!( + "Sending firewall config update for location {location} affected by adding new SNAT binding" + ); + appstate.send_wireguard_event(GatewayEvent::FirewallConfigChanged( + location_id, + firewall_config, + )); + } + } + + Ok(ApiResponse { + json: json!(snat_binding), + status: StatusCode::OK, + }) +} + +/// 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}", + tag = "SNAT", + params( + ("location_id" = Id, Path, description = "WireGuard location ID"), + ("user_id" = Id, Path, description = "User ID") + ), + responses( + (status = 200, description = "SNAT binding deleted successfully"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin role required"), + (status = 404, description = "Not found - SNAT binding does not exist"), + (status = 500, description = "Internal server error") + ), + security( + ("cookie" = []), + ("api_token" = []) + ) +)] +pub async fn delete_snat_binding( + _license: LicenseInfo, + _admin_role: AdminRole, + session: SessionInfo, + context: ApiRequestContext, + Path((location_id, user_id)): Path<(Id, Id)>, + State(appstate): State, +) -> ApiResult { + let current_user = session.user.username; + + // fetch relevant location & user + let location = WireguardNetwork::find_by_id(&appstate.pool, location_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("Location {location_id} not found")))?; + let snat_user = User::find_by_id(&appstate.pool, user_id) + .await? + .ok_or_else(|| WebError::ObjectNotFound(format!("User {user_id} not found")))?; + + debug!( + "User {current_user} deleting SNAT binding for user {snat_user} and WireGuard location {location}" + ); + + // fetch existing binding + let snat_binding = UserSnatBinding::find_binding(&appstate.pool, location_id, user_id).await?; + + // delete binding + snat_binding.clone().delete(&appstate.pool).await?; + + // emit event + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::UserSnatBindingRemoved { + user: snat_user, + location: location.clone(), + binding: snat_binding, + }), + })?; + + // trigger firewall config update on relevant gateways + let mut conn = appstate.pool.acquire().await?; + if let Some(location) = WireguardNetwork::find_by_id(&appstate.pool, location_id).await? { + if let Some(firewall_config) = location.try_get_firewall_config(&mut conn).await? { + debug!( + "Sending firewall config update for location {location} affected by adding new SNAT binding" + ); + appstate.send_wireguard_event(GatewayEvent::FirewallConfigChanged( + location_id, + firewall_config, + )); + } + } + + Ok(ApiResponse::default()) +} diff --git a/crates/defguard_core/src/enterprise/snat/mod.rs b/crates/defguard_core/src/enterprise/snat/mod.rs new file mode 100644 index 0000000000..00d363536e --- /dev/null +++ b/crates/defguard_core/src/enterprise/snat/mod.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod handlers; diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index eb382bbc4b..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), @@ -33,6 +34,8 @@ pub enum WebError { IncorrectUsername(String), #[error("Object not found: {0}")] ObjectNotFound(String), + #[error("Object already exists: {0}")] + ObjectAlreadyExists(String), #[error("Serialization error: {0}")] Serialization(String), #[error("Deserialization error: {0}")] @@ -50,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), } @@ -138,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 0a534da83c..59c3d9ffcf 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -1,15 +1,26 @@ use std::net::IpAddr; use chrono::{NaiveDateTime, Utc}; +use serde::Serialize; -use crate::db::{Device, Id, MFAMethod, WireguardNetwork}; +use crate::{ + db::{ + Device, Group, Id, MFAMethod, Settings, User, WebAuthn, WebHook, WireguardNetwork, + models::{authentication_key::AuthenticationKey, oauth2client::OAuth2Client}, + }, + enterprise::db::models::{ + activity_log_stream::ActivityLogStream, api_tokens::ApiToken, + openid_provider::OpenIdProvider, snat::UserSnatBinding, + }, + grpc::proto::proxy::MfaMethod, +}; /// Shared context that needs to be added to every API event /// /// Mainly meant to be stored in the activity log. /// By design this is a duplicate of a similar struct in the `event_logger` module. /// This is done in order to avoid circular imports once we split the project into multiple crates. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ApiRequestContext { pub timestamp: NaiveDateTime, pub user_id: Id, @@ -19,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 { @@ -42,15 +54,18 @@ pub struct GrpcRequestContext { pub ip: IpAddr, pub device_id: Id, pub device_name: String, + pub location: WireguardNetwork, } impl GrpcRequestContext { + #[must_use] pub fn new( user_id: Id, username: String, ip: IpAddr, device_id: Id, device_name: String, + location: WireguardNetwork, ) -> Self { let timestamp = Utc::now().naive_utc(); Self { @@ -60,6 +75,7 @@ impl GrpcRequestContext { ip, device_id, device_name, + location, } } } @@ -67,81 +83,216 @@ impl GrpcRequestContext { #[derive(Debug)] pub enum ApiEventType { UserLogin, - UserLoginFailed, + UserLoginFailed { + message: String, + }, + UserLogout, UserMfaLogin { mfa_method: MFAMethod, }, UserMfaLoginFailed { mfa_method: MFAMethod, + message: String, }, RecoveryCodeUsed, - UserLogout, + PasswordChangedByAdmin { + user: User, + }, + PasswordChanged, + PasswordReset { + user: User, + }, MfaDisabled, + UserMfaDisabled { + user: User, + }, MfaTotpDisabled, MfaTotpEnabled, MfaEmailDisabled, MfaEmailEnabled, MfaSecurityKeyAdded { - key_id: Id, - key_name: String, + key: WebAuthn, }, MfaSecurityKeyRemoved { - key_id: Id, - key_name: String, + key: WebAuthn, }, UserAdded { - username: String, + user: User, }, UserRemoved { - username: String, + user: User, }, UserModified { - username: String, + before: User, + after: User, + }, + UserGroupsModified { + user: User, + before: Vec, + after: Vec, }, UserDeviceAdded { - device_id: Id, - owner: String, - device_name: String, + owner: User, + device: Device, }, UserDeviceRemoved { - device_id: Id, - owner: String, - device_name: String, + owner: User, + device: Device, }, UserDeviceModified { - device_id: Id, - owner: String, - device_name: String, + owner: User, + before: Device, + after: Device, }, NetworkDeviceAdded { - device_id: Id, - device_name: String, - location_id: Id, - location: String, + device: Device, + location: WireguardNetwork, }, NetworkDeviceRemoved { - device_id: Id, - device_name: String, - location_id: Id, - location: String, + device: Device, + location: WireguardNetwork, }, NetworkDeviceModified { - device_id: Id, - device_name: String, - location_id: Id, - location: String, + before: Device, + after: Device, + location: WireguardNetwork, }, ActivityLogStreamCreated { - stream_id: Id, - stream_name: String, + stream: ActivityLogStream, }, ActivityLogStreamModified { - stream_id: Id, - stream_name: String, + before: ActivityLogStream, + after: ActivityLogStream, }, ActivityLogStreamRemoved { - stream_id: Id, - stream_name: String, + stream: ActivityLogStream, + }, + VpnLocationAdded { + location: WireguardNetwork, + }, + VpnLocationRemoved { + location: WireguardNetwork, + }, + VpnLocationModified { + before: WireguardNetwork, + after: WireguardNetwork, + }, + ApiTokenAdded { + owner: User, + token: ApiToken, + }, + ApiTokenRemoved { + owner: User, + token: ApiToken, + }, + ApiTokenRenamed { + owner: User, + token: ApiToken, + old_name: String, + new_name: String, + }, + OpenIdAppAdded { + app: OAuth2Client, + }, + OpenIdAppRemoved { + app: OAuth2Client, + }, + OpenIdAppModified { + before: OAuth2Client, + after: OAuth2Client, + }, + OpenIdAppStateChanged { + app: OAuth2Client, + enabled: bool, + }, + OpenIdProviderModified { + provider: OpenIdProvider, + }, + OpenIdProviderRemoved { + provider: OpenIdProvider, + }, + SettingsUpdated { + before: Settings, + after: Settings, + }, + SettingsUpdatedPartial { + before: Settings, + after: Settings, + }, + SettingsDefaultBrandingRestored, + GroupsBulkAssigned { + users: Vec>, + groups: Vec>, + }, + GroupAdded { + group: Group, + }, + GroupModified { + before: Group, + after: Group, + }, + GroupRemoved { + group: Group, + }, + GroupMemberAdded { + group: Group, + user: User, + }, + GroupMemberRemoved { + group: Group, + user: User, + }, + GroupMembersModified { + group: Group, + added: Vec>, + removed: Vec>, + }, + WebHookAdded { + webhook: WebHook, + }, + WebHookModified { + before: WebHook, + after: WebHook, + }, + WebHookRemoved { + webhook: WebHook, + }, + WebHookStateChanged { + webhook: WebHook, + enabled: bool, + }, + AuthenticationKeyAdded { + key: AuthenticationKey, + }, + AuthenticationKeyRemoved { + key: AuthenticationKey, + }, + AuthenticationKeyRenamed { + key: AuthenticationKey, + old_name: Option, + new_name: Option, + }, + EnrollmentTokenAdded { + user: User, + }, + ClientConfigurationTokenAdded { + user: User, + }, + UserSnatBindingAdded { + user: User, + location: WireguardNetwork, + binding: UserSnatBinding, + }, + UserSnatBindingRemoved { + user: User, + location: WireguardNetwork, + binding: UserSnatBinding, + }, + UserSnatBindingModified { + user: User, + location: WireguardNetwork, + before: UserSnatBinding, + after: UserSnatBinding, }, } @@ -149,14 +300,18 @@ pub enum ApiEventType { #[derive(Debug)] pub struct ApiEvent { pub context: ApiRequestContext, - pub event: ApiEventType, + pub event: Box, } /// Events from gRPC server #[derive(Debug)] pub enum GrpcEvent { - GatewayConnected, - GatewayDisconnected, + GatewayConnected { + location: WireguardNetwork, + }, + GatewayDisconnected { + location: WireguardNetwork, + }, ClientConnected { context: GrpcRequestContext, location: WireguardNetwork, @@ -178,18 +333,19 @@ pub struct BidiRequestContext { pub user_id: Id, pub username: String, pub ip: IpAddr, - pub user_agent: String, + pub device_name: String, } impl BidiRequestContext { - pub fn new(user_id: Id, username: String, ip: IpAddr, user_agent: String) -> Self { + #[must_use] + pub fn new(user_id: Id, username: String, ip: IpAddr, device_name: String) -> Self { let timestamp = Utc::now().naive_utc(); Self { timestamp, user_id, username, ip, - user_agent, + device_name, } } } @@ -204,12 +360,11 @@ pub struct BidiStreamEvent { /// Wrapper enum for different types of events emitted by the bidi stream. /// /// Each variant represents a separate gRPC service that's part of the bi-directional communications server. -#[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum BidiStreamEventType { - Enrollment(EnrollmentEvent), - PasswordReset(PasswordResetEvent), - DesktopClientMfa(DesktopClientMfaEvent), + Enrollment(Box), + PasswordReset(Box), + DesktopClientMfa(Box), } #[derive(Debug)] @@ -226,23 +381,43 @@ pub enum PasswordResetEvent { PasswordResetCompleted, } +pub type ClientMFAMethod = MfaMethod; + +impl Serialize for ClientMFAMethod { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match *self { + 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") + } + } + } +} + #[derive(Debug)] pub enum DesktopClientMfaEvent { Connected { device: Device, location: WireguardNetwork, - method: MFAMethod, + method: ClientMFAMethod, }, Failed { device: Device, location: WireguardNetwork, - method: MFAMethod, + method: ClientMFAMethod, + message: String, }, } /// Shared context for every internally-triggered event. /// -/// Similarly to `ApiRequestContexts` at the moment it's mostly meant to populate the audit log. +/// Similarly to `ApiRequestContexts` at the moment it's mostly meant to populate the activity log. #[derive(Debug)] pub struct InternalEventContext { pub timestamp: NaiveDateTime, @@ -253,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/auth.rs b/crates/defguard_core/src/grpc/auth.rs index 31ec616d49..99ebd75608 100644 --- a/crates/defguard_core/src/grpc/auth.rs +++ b/crates/defguard_core/src/grpc/auth.rs @@ -6,8 +6,8 @@ use tonic::{Request, Response, Status}; use crate::{ auth::{ - failed_login::{check_failed_logins, log_failed_login_attempt, FailedLoginMap}, Claims, ClaimsType, + failed_login::{FailedLoginMap, check_failed_logins, log_failed_login_attempt}, }, db::User, server_config, diff --git a/crates/defguard_core/src/grpc/client_mfa.rs b/crates/defguard_core/src/grpc/client_mfa.rs new file mode 100644 index 0000000000..f7ac11e548 --- /dev/null +++ b/crates/defguard_core/src/grpc/client_mfa.rs @@ -0,0 +1,664 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sqlx::PgPool; +use thiserror::Error; +use tokio::sync::{ + broadcast::Sender, + mpsc::{UnboundedSender, error::SendError}, +}; +use tonic::{Code, Status}; + +use super::proto::proxy::{ + self, ClientMfaFinishRequest, ClientMfaFinishResponse, ClientMfaStartRequest, + ClientMfaStartResponse, MfaMethod, +}; +use crate::{ + auth::{Claims, ClaimsType}, + 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::{ + proto::proxy::{ClientMfaTokenValidationRequest, ClientMfaTokenValidationResponse}, + utils::parse_client_info, + }, + handlers::mail::send_email_mfa_code_email, + mail::Mail, +}; + +const CLIENT_SESSION_TIMEOUT: u64 = 60 * 5; // 10 minutes + +#[derive(Debug, Error)] +pub enum ClientMfaServerError { + #[error("gRPC event channel error: {0}")] + BidiEventChannelError(#[from] SendError), +} + +impl From for Status { + fn from(value: ClientMfaServerError) -> Self { + Self::new(Code::Internal, value.to_string()) + } +} + +#[derive(Clone)] +pub(crate) struct ClientLoginSession { + pub(crate) method: MfaMethod, + pub(crate) location: WireguardNetwork, + pub(crate) device: Device, + pub(crate) user: User, + pub(crate) openid_auth_completed: bool, + pub(crate) biometric_challenge: Option, +} + +pub(crate) struct ClientMfaServer { + pub(crate) pool: PgPool, + mail_tx: UnboundedSender, + wireguard_tx: Sender, + pub(crate) sessions: HashMap, + bidi_event_tx: UnboundedSender, +} + +impl ClientMfaServer { + #[must_use] + pub fn new( + pool: PgPool, + mail_tx: UnboundedSender, + wireguard_tx: Sender, + bidi_event_tx: UnboundedSender, + ) -> Self { + Self { + pool, + mail_tx, + wireguard_tx, + bidi_event_tx, + sessions: HashMap::new(), + } + } + + fn generate_token(pubkey: &str) -> Result { + Claims::new( + ClaimsType::DesktopClient, + String::new(), + pubkey.into(), + CLIENT_SESSION_TIMEOUT, + ) + .to_jwt() + .map_err(|err| { + error!("Failed to generate JWT token: {err:?}"); + Status::internal("unexpected error") + }) + } + + /// Validate JWT and extract client pubkey + pub(crate) fn parse_token(token: &str) -> Result { + let claims = Claims::from_jwt(ClaimsType::DesktopClient, token).map_err(|err| { + error!("Failed to parse JWT token: {err:?}"); + Status::invalid_argument("invalid token") + })?; + Ok(claims.client_id) + } + + pub(crate) fn emit_event(&self, event: BidiStreamEvent) -> Result<(), ClientMfaServerError> { + 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, + request: ClientMfaStartRequest, + ) -> Result { + debug!("Starting desktop client login: {request:?}"); + // fetch location + let Ok(Some(location)) = + WireguardNetwork::find_by_id(&self.pool, request.location_id).await + else { + error!("Failed to find location with ID {}", request.location_id); + return Err(Status::invalid_argument("location not found")); + }; + + // return early if MFA is not enabled for this location + if !location.mfa_enabled() { + error!("MFA is not enabled for location {location}"); + return Err(Status::invalid_argument("MFA not enabled for location")); + } + + // fetch device + let Ok(Some(device)) = Device::find_by_pubkey(&self.pool, &request.pubkey).await else { + error!("Failed to find device with pubkey {}", request.pubkey); + return Err(Status::invalid_argument("device not found")); + }; + + // fetch user + let Ok(Some(mut user)) = User::find_by_id(&self.pool, device.user_id).await else { + error!("Failed to find user with ID {}", device.user_id); + return Err(Status::invalid_argument("user not found")); + }; + let user_info = UserInfo::from_user(&self.pool, &user).await.map_err(|_| { + error!("Failed to fetch user info for {}", user.username); + Status::internal("unexpected error") + })?; + + // validate user is allowed to connect to a given location + Self::validate_location_access(&self.pool, &location, &user_info).await?; + + user.verify_mfa_state(&self.pool).await.map_err(|err| { + error!( + "Failed to verify MFA state for user {}: {err:?}", + user.username + ); + Status::internal("unexpected error") + })?; + + // extract user selected method from request + let selected_method = MfaMethod::try_from(request.method).map_err(|err| { + error!("Invalid MFA method selected ({}): {err}", request.method); + Status::invalid_argument("invalid MFA method selected") + })?; + + // check if selected MFA method matches location settings + match (&location.location_mfa_mode, selected_method) { + // MFA enabled status is already verified + (LocationMfaMode::Disabled, _) => unreachable!(), + ( + 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}"); + } + _ => { + error!( + "Selected MFA method ({selected_method}) is not supported by location \ + {location} which uses {}", + location.location_mfa_mode + ); + + return Err(Status::invalid_argument( + "selected MFA method not supported by location", + )); + } + } + + 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); + return Err(Status::invalid_argument( + "selected MFA method not available", + )); + } + } + MfaMethod::Email => { + if !user.email_mfa_enabled { + error!("Email MFA not enabled for user {}", user.username); + return Err(Status::invalid_argument( + "selected MFA method not available", + )); + } + // send email code + send_email_mfa_code_email(&user, &self.mail_tx, None).map_err(|err| { + error!( + "Failed to send email MFA code for user {}: {err:?}", + user.username + ); + Status::internal("unexpected error") + })?; + } + MfaMethod::Oidc => { + if !is_enterprise_enabled() { + error!("OIDC MFA method requires enterprise feature to be enabled"); + return Err(Status::invalid_argument( + "selected MFA method not available", + )); + } + + if OpenIdProvider::get_current(&self.pool) + .await + .map_err(|err| { + error!("Failed to get current OpenID provider: {err:?}",); + Status::internal("unexpected error") + })? + .is_none() + { + error!("OIDC provider is not configured"); + return Err(Status::invalid_argument( + "selected MFA method not available", + )); + } + } + } + + // generate auth token + let token = Self::generate_token(&request.pubkey)?; + + info!( + "Desktop client MFA login started for {} at location {}", + 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, + ClientLoginSession { + method: selected_method, + location, + device, + user, + openid_auth_completed: false, + biometric_challenge, + }, + ); + + Ok(ClientMfaStartResponse { + token, + challenge: response_challenge, + }) + } + + /// Checks if given user is allowed to access a location + async fn validate_location_access( + pool: &PgPool, + location: &WireguardNetwork, + user_info: &UserInfo, + ) -> Result<(), Status> { + // acquire connection + let mut conn = pool.acquire().await.map_err(|_| { + error!("Failed to acquire DB connection"); + Status::internal("unexpected error") + })?; + + // fetch allowed group names for a given location + let allowed_groups = location + .get_allowed_groups(&mut conn) + .await + .map_err(|err| { + error!("Failed to fetch allowed groups for location {location}: {err:?}"); + Status::internal("unexpected error") + })?; + // if no groups are specified all users are allowed + if let Some(groups) = allowed_groups { + // check if user belongs to one of allowed groups + if !groups + .iter() + .any(|allowed_group| user_info.groups.contains(allowed_group)) + { + error!( + "User {} not allowed to connect to location {location} because he doesn't belong to any of the allowed groups. + User groups: {:?}, allowed groups: {:?}", + user_info.username, user_info.groups, groups + ); + return Err(Status::unauthenticated("unauthorized")); + } + } + Ok(()) + } + + #[instrument(skip_all)] + pub async fn finish_client_mfa_login( + &mut self, + request: ClientMfaFinishRequest, + info: Option, + ) -> Result { + debug!("Finishing desktop client login: {request:?}"); + // get pubkey from token + let pubkey = Self::parse_token(&request.token)?; + + // fetch login session + let Some(session) = self.sessions.get(&pubkey) else { + error!("Client login session not found"); + return Err(Status::invalid_argument("login session not found")); + }; + let ClientLoginSession { + method, + device, + location, + user, + openid_auth_completed, + biometric_challenge, + } = session; + + // Prepare event context + let (ip, _user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let context = BidiRequestContext::new( + user.id, + user.username.clone(), + ip, + format!("{} (ID {})", device.name, device.id), + ); + + // 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() + } else { + error!("TOTP code not provided in request"); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + location: location.clone(), + device: device.clone(), + method: *method, + message: "TOTP code not provided in request".to_string(), + }, + )), + })?; + return Err(Status::invalid_argument("TOTP code not provided")); + }; + if !user.verify_totp_code(&code) { + error!("Provided TOTP code is not valid"); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + location: location.clone(), + device: device.clone(), + method: *method, + message: "invalid TOTP code".to_string(), + }, + )), + })?; + return Err(Status::unauthenticated("unauthorized")); + } + } + MfaMethod::Email => { + let code = if let Some(code) = request.code { + code.to_string() + } else { + error!("Email MFA code not provided in request"); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + location: location.clone(), + device: device.clone(), + method: *method, + message: "email MFA code not provided in request".to_string(), + }, + )), + })?; + return Err(Status::invalid_argument("email MFA code not provided")); + }; + if !user.verify_email_mfa_code(&code) { + error!("Provided email code is not valid"); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + location: location.clone(), + device: device.clone(), + method: *method, + message: "invalid email MFA code".to_string(), + }, + )), + })?; + return Err(Status::unauthenticated("unauthorized")); + } + } + MfaMethod::Oidc => { + if !*openid_auth_completed { + debug!( + "User {user} tried to finish OIDC MFA login but they haven't completed \ + the OIDC authentication yet." + ); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Failed { + 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(), + }, + )), + })?; + return Err(Status::failed_precondition( + "OIDC authentication not completed yet", + )); + } + debug!( + "User {user} is trying to finish OIDC MFA login and the OIDC authentication \ + has already been completed; proceeding." + ); + } + } + + // begin transaction + let mut transaction = self.pool.begin().await.map_err(|_| { + error!("Failed to begin transaction"); + Status::internal("unexpected error") + })?; + + // fetch device config for the location + let Ok(Some(mut network_device)) = + WireguardNetworkDevice::find(&mut *transaction, device.id, location.id).await + else { + error!("Failed to fetch network config for device {device} and location {location}"); + return Err(Status::internal("unexpected error")); + }; + + // generate PSK + let key = WireguardNetwork::genkey(); + network_device.preshared_key = Some(key.public.clone()); + + // authorize device for given location + network_device.is_authorized = true; + network_device.authorized_at = Some(Utc::now().naive_utc()); + + // save updated network config + network_device + .update(&mut *transaction) + .await + .map_err(|err| { + error!("Failed to update device network config {network_device:?}: {err:?}"); + Status::internal("unexpected error") + })?; + + // send gateway event + debug!("Sending `peer_create` message to gateway"); + let device_info = DeviceInfo { + device: device.clone(), + network_info: vec![DeviceNetworkInfo { + network_id: location.id, + device_wireguard_ips: network_device.wireguard_ips, + preshared_key: network_device.preshared_key, + is_authorized: network_device.is_authorized, + }], + }; + let event = GatewayEvent::DeviceCreated(device_info); + self.wireguard_tx.send(event).map_err(|err| { + error!("Error sending WireGuard event: {err}"); + Status::internal("unexpected error") + })?; + + info!( + "Desktop client login finished for {} at location {} with method {}", + user.username, + location.name, + method.as_str_name() + ); + self.emit_event(BidiStreamEvent { + context, + event: BidiStreamEventType::DesktopClientMfa(Box::new( + DesktopClientMfaEvent::Connected { + location: location.clone(), + device: device.clone(), + method: *method, + }, + )), + })?; + + 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); + + // commit transaction + transaction.commit().await.map_err(|_| { + error!("Failed to commit transaction while finishing desktop client login."); + Status::internal("unexpected error") + })?; + + Ok(response) + } +} diff --git a/crates/defguard_core/src/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/grpc/desktop_client_mfa.rs deleted file mode 100644 index f0909651c3..0000000000 --- a/crates/defguard_core/src/grpc/desktop_client_mfa.rs +++ /dev/null @@ -1,357 +0,0 @@ -use std::collections::HashMap; - -use chrono::Utc; -use sqlx::PgPool; -use thiserror::Error; -use tokio::sync::{ - broadcast::Sender, - mpsc::{error::SendError, UnboundedSender}, -}; -use tonic::{Code, Status}; - -use super::proto::proxy::{ - self, ClientMfaFinishRequest, ClientMfaFinishResponse, ClientMfaStartRequest, - ClientMfaStartResponse, MfaMethod, -}; -use crate::{ - auth::{Claims, ClaimsType}, - db::{ - models::device::{DeviceInfo, DeviceNetworkInfo, WireguardNetworkDevice}, - Device, GatewayEvent, Id, User, UserInfo, WireguardNetwork, - }, - events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, - grpc::utils::parse_client_info, - handlers::mail::send_email_mfa_code_email, - mail::Mail, -}; - -const CLIENT_SESSION_TIMEOUT: u64 = 60 * 5; // 10 minutes - -#[derive(Debug, Error)] -#[allow(clippy::large_enum_variant)] -pub enum ClientMfaServerError { - #[error("gRPC event channel error: {0}")] - BidiEventChannelError(#[from] SendError), -} - -impl From for Status { - fn from(value: ClientMfaServerError) -> Self { - Self::new(Code::Internal, value.to_string()) - } -} - -struct ClientLoginSession { - method: MfaMethod, - location: WireguardNetwork, - device: Device, - user: User, -} - -pub(super) struct ClientMfaServer { - pool: PgPool, - mail_tx: UnboundedSender, - wireguard_tx: Sender, - sessions: HashMap, - bidi_event_tx: UnboundedSender, -} - -impl ClientMfaServer { - #[must_use] - pub fn new( - pool: PgPool, - mail_tx: UnboundedSender, - wireguard_tx: Sender, - bidi_event_tx: UnboundedSender, - ) -> Self { - Self { - pool, - mail_tx, - wireguard_tx, - bidi_event_tx, - sessions: HashMap::new(), - } - } - - fn generate_token(pubkey: &str) -> Result { - Claims::new( - ClaimsType::DesktopClient, - String::new(), - pubkey.into(), - CLIENT_SESSION_TIMEOUT, - ) - .to_jwt() - .map_err(|err| { - error!("Failed to generate JWT token: {err:?}"); - Status::internal("unexpected error") - }) - } - - /// Validate JWT and extract client pubkey - fn parse_token(token: &str) -> Result { - let claims = Claims::from_jwt(ClaimsType::DesktopClient, token).map_err(|err| { - error!("Failed to parse JWT token: {err:?}"); - Status::invalid_argument("invalid token") - })?; - Ok(claims.client_id) - } - - fn emit_event(&self, event: BidiStreamEvent) -> Result<(), ClientMfaServerError> { - Ok(self.bidi_event_tx.send(event)?) - } - - #[instrument(skip_all)] - pub async fn start_client_mfa_login( - &mut self, - request: ClientMfaStartRequest, - ) -> Result { - debug!("Starting desktop client login: {request:?}"); - // fetch location - let Ok(Some(location)) = - WireguardNetwork::find_by_id(&self.pool, request.location_id).await - else { - error!("Failed to find location with ID {}", request.location_id); - return Err(Status::invalid_argument("location not found")); - }; - - // fetch device - let Ok(Some(device)) = Device::find_by_pubkey(&self.pool, &request.pubkey).await else { - error!("Failed to find device with pubkey {}", request.pubkey); - return Err(Status::invalid_argument("device not found")); - }; - - // fetch user - let Ok(Some(mut user)) = User::find_by_id(&self.pool, device.user_id).await else { - error!("Failed to find user with ID {}", device.user_id); - return Err(Status::invalid_argument("user not found")); - }; - let user_info = UserInfo::from_user(&self.pool, &user).await.map_err(|_| { - error!("Failed to fetch user info for {}", user.username); - Status::internal("unexpected error") - })?; - - // validate user is allowed to connect to a given location - let mut transaction = self.pool.begin().await.map_err(|_| { - error!("Failed to begin transaction"); - Status::internal("unexpected error") - })?; - let allowed_groups = location - .get_allowed_groups(&mut transaction) - .await - .map_err(|err| { - error!("Failed to fetch allowed groups for location {location}: {err:?}"); - Status::internal("unexpected error") - })?; - if let Some(groups) = allowed_groups { - // check if user belongs to one of allowed groups - if !groups - .iter() - .any(|allowed_group| user_info.groups.contains(allowed_group)) - { - error!( - "User {} not allowed to connect to location {location} because he doesn't belong to any of the allowed groups. - User groups: {:?}, allowed groups: {:?}", - user.username, user_info.groups, groups - ); - return Err(Status::unauthenticated("unauthorized")); - } - } - - user.verify_mfa_state(&self.pool).await.map_err(|err| { - error!( - "Failed to verify MFA state for user {}: {err:?}", - user.username - ); - Status::internal("unexpected error") - })?; - - // check if selected method is enabled - let method = MfaMethod::try_from(request.method).map_err(|err| { - error!("Invalid MFA method selected ({}): {err}", request.method); - Status::invalid_argument("invalid MFA method selected") - })?; - match method { - MfaMethod::Totp => { - if !user.totp_enabled { - error!("TOTP not enabled for user {}", user.username); - return Err(Status::invalid_argument( - "selected MFA method not available", - )); - } - } - MfaMethod::Email => { - if !user.email_mfa_enabled { - error!("Email MFA not enabled for user {}", user.username); - return Err(Status::invalid_argument( - "selected MFA method not available", - )); - } - // send email code - send_email_mfa_code_email(&user, &self.mail_tx, None).map_err(|err| { - error!( - "Failed to send email MFA code for user {}: {err:?}", - user.username - ); - Status::internal("unexpected error") - })?; - } - } - - // generate auth token - let token = Self::generate_token(&request.pubkey)?; - - info!( - "Desktop client MFA login started for {} at location {}", - user.username, location.name - ); - - // store login session - self.sessions.insert( - request.pubkey, - ClientLoginSession { - method, - location, - device, - user, - }, - ); - - Ok(ClientMfaStartResponse { token }) - } - - #[instrument(skip_all)] - pub async fn finish_client_mfa_login( - &mut self, - request: ClientMfaFinishRequest, - info: Option, - ) -> Result { - debug!("Finishing desktop client login: {request:?}"); - // get pubkey from token - let pubkey = Self::parse_token(&request.token)?; - - // fetch login session - let Some(session) = self.sessions.get(&pubkey) else { - error!("Client login session not found"); - return Err(Status::invalid_argument("login session not found")); - }; - let ClientLoginSession { - method, - device, - location, - user, - } = session; - - // Prepare event context - let (ip, user_agent) = parse_client_info(&info).map_err(Status::internal)?; - let context = BidiRequestContext::new(user.id, user.username.clone(), ip, user_agent); - - // validate code - match method { - MfaMethod::Totp => { - if !user.verify_totp_code(&request.code.to_string()) { - error!("Provided TOTP code is not valid"); - self.emit_event(BidiStreamEvent { - context, - event: BidiStreamEventType::DesktopClientMfa( - DesktopClientMfaEvent::Failed { - location: location.clone(), - device: device.clone(), - method: (*method).into(), - }, - ), - })?; - return Err(Status::unauthenticated("unauthorized")); - } - } - MfaMethod::Email => { - if !user.verify_email_mfa_code(&request.code.to_string()) { - error!("Provided email code is not valid"); - self.emit_event(BidiStreamEvent { - context, - event: BidiStreamEventType::DesktopClientMfa( - DesktopClientMfaEvent::Failed { - location: location.clone(), - device: device.clone(), - method: (*method).into(), - }, - ), - })?; - return Err(Status::unauthenticated("unauthorized")); - } - } - } - - // begin transaction - let mut transaction = self.pool.begin().await.map_err(|_| { - error!("Failed to begin transaction"); - Status::internal("unexpected error") - })?; - - // fetch device config for the location - let Ok(Some(mut network_device)) = - WireguardNetworkDevice::find(&mut *transaction, device.id, location.id).await - else { - error!("Failed to fetch network config for device {device} and location {location}"); - return Err(Status::internal("unexpected error")); - }; - - // generate PSK - let key = WireguardNetwork::genkey(); - network_device.preshared_key = Some(key.public.clone()); - - // authorize device for given location - network_device.is_authorized = true; - network_device.authorized_at = Some(Utc::now().naive_utc()); - - // save updated network config - network_device - .update(&mut *transaction) - .await - .map_err(|err| { - error!("Failed to update device network config {network_device:?}: {err:?}"); - Status::internal("unexpected error") - })?; - - // send gateway event - debug!("Sending `peer_create` message to gateway"); - let device_info = DeviceInfo { - device: device.clone(), - network_info: vec![DeviceNetworkInfo { - network_id: location.id, - device_wireguard_ips: network_device.wireguard_ips, - preshared_key: network_device.preshared_key, - is_authorized: network_device.is_authorized, - }], - }; - let event = GatewayEvent::DeviceCreated(device_info); - self.wireguard_tx.send(event).map_err(|err| { - error!("Error sending WireGuard event: {err}"); - Status::internal("unexpected error") - })?; - - info!( - "Desktop client login finished for {} at location {}", - user.username, location.name - ); - self.emit_event(BidiStreamEvent { - context, - event: BidiStreamEventType::DesktopClientMfa(DesktopClientMfaEvent::Connected { - location: location.clone(), - device: device.clone(), - method: (*method).into(), - }), - })?; - - // remove login session from map - self.sessions.remove(&pubkey); - - // commit transaction - transaction.commit().await.map_err(|_| { - error!("Failed to commit transaction while finishing desktop client login."); - Status::internal("unexpected error") - })?; - - Ok(ClientMfaFinishResponse { - preshared_key: key.public, - }) - } -} diff --git a/crates/defguard_core/src/grpc/enrollment.rs b/crates/defguard_core/src/grpc/enrollment.rs index 8445cc2b70..4490c08582 100644 --- a/crates/defguard_core/src/grpc/enrollment.rs +++ b/crates/defguard_core/src/grpc/enrollment.rs @@ -1,41 +1,56 @@ use std::collections::HashSet; -use sqlx::{PgPool, Transaction}; +use sqlx::{PgPool, Transaction, query_scalar}; use tokio::sync::{ broadcast::Sender, - mpsc::{error::SendError, UnboundedSender}, + mpsc::{UnboundedSender, error::SendError}, }; use tonic::Status; use super::{ + InstanceInfo, proto::proxy::{ ActivateUserRequest, AdminInfo, Device as ProtoDevice, DeviceConfig as ProtoDeviceConfig, DeviceConfigResponse, EnrollmentStartRequest, EnrollmentStartResponse, ExistingDevice, InitialUserInfo, NewDevice, }, - InstanceInfo, }; use crate::{ + AsCsv, db::{ + Device, GatewayEvent, Id, MFAMethod, Settings, User, WireguardNetwork, models::{ + biometric_auth::BiometricAuth, device::{DeviceConfig, DeviceInfo, DeviceType}, - enrollment::{Token, TokenError, ENROLLMENT_TOKEN_TYPE}, + enrollment::{ENROLLMENT_TOKEN_TYPE, Token, TokenError}, polling_token::PollingToken, + wireguard::LocationMfaMode, }, - Device, GatewayEvent, Id, Settings, User, WireguardNetwork, }, enterprise::{ - db::models::enterprise_settings::EnterpriseSettings, ldap::utils::ldap_add_user, + db::models::{enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider}, + ldap::utils::ldap_add_user, limits::update_counts, }, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, EnrollmentEvent}, - grpc::utils::{build_device_config_response, new_polling_token, parse_client_info}, - handlers::{mail::send_new_device_added_email, user::check_password_strength}, + grpc::{ + 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_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, templates::{self, TemplateLocation}, - AsCsv, }; pub(super) struct EnrollmentServer { @@ -105,7 +120,7 @@ impl EnrollmentServer { ) -> Result<(), SendError> { let event = BidiStreamEvent { context, - event: BidiStreamEventType::Enrollment(event), + event: BidiStreamEventType::Enrollment(Box::new(event)), }; self.bidi_event_tx.send(event) @@ -194,7 +209,20 @@ impl EnrollmentServer { "Retrieving instance info for user {}({:?}).", user.username, user.id ); - let instance_info = InstanceInfo::new(settings, &user.username, &enterprise_settings); + + let openid_provider = OpenIdProvider::get_current(&self.pool) + .await + .map_err(|err| { + 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, + &enterprise_settings, + openid_provider, + ); debug!("Instance info {instance_info:?}"); debug!( @@ -217,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 @@ -228,9 +253,22 @@ impl EnrollmentServer { error!("Failed to get enterprise settings: {err}"); Status::internal("unexpected error") })?; - let enrollment_settings = super::proto::proxy::Settings { + // 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, @@ -265,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, @@ -334,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..."); @@ -399,6 +477,34 @@ impl EnrollmentServer { // fetch related users let user = enrollment_token.fetch_user(&self.pool).await?; + // check if adding device by non-admin users is allowed + debug!( + "Fetching enterprise settings for device creation process for user {}({:?})", + user.username, user.id, + ); + let enterprise_settings = EnterpriseSettings::get(&self.pool).await.map_err(|err| { + error!( + "Failed to fetch enterprise settings for device creation process for user {}({:?}): \ + {err}", + user.username, user.id, + ); + Status::internal("unexpected error") + })?; + debug!("Enterprise settings: {enterprise_settings:?}"); + + if !user.is_admin(&self.pool).await.map_err(|err| { + error!( + "Failed to fetch admin status for user {}({:?}): {err}", + user.username, user.id, + ); + Status::internal("unexpected error") + })? && enterprise_settings.admin_device_management + { + return Err(Status::invalid_argument( + "only admin users can manage devices", + )); + } + // add device debug!( "Verifying if user {}({:?}) is active", @@ -463,11 +569,7 @@ impl EnrollmentServer { { warn!( "User {}({:?}) failed to add device {}, identical pubkey ({}) already exists for device {}", - user.username, - user.id, - request.name, - request.pubkey, - device.name + user.username, user.id, request.name, request.pubkey, device.name ); return Err(Status::invalid_argument("invalid key")); } @@ -632,7 +734,10 @@ impl EnrollmentServer { Status::internal("unexpected error") })? { - debug!("Sending firewall config update for location {location} affected by adding new device {}, user {}({})", device.wireguard_pubkey, user.username, user.id); + debug!( + "Sending firewall config update for location {location} affected by adding new device {}, user {}({})", + device.wireguard_pubkey, user.username, user.id + ); self.send_wireguard_event(GatewayEvent::FirewallConfigChanged( location_id, firewall_config, @@ -661,23 +766,6 @@ impl EnrollmentServer { let settings = Settings::get_current_settings(); debug!("Settings: {settings:?}"); - debug!( - "Fetching enterprise settings for device {} creation process for user {}({:?})", - device.wireguard_pubkey, user.username, user.id, - ); - let enterprise_settings = - EnterpriseSettings::get(&mut *transaction) - .await - .map_err(|err| { - error!( - "Failed to fetch enterprise settings for device {} creation process for user {}({:?}): \ - {err}", - device.wireguard_pubkey, user.username, user.id, - ); - Status::internal("unexpected error") - })?; - debug!("Enterprise settings: {enterprise_settings:?}"); - // create polling token for further client communication debug!( "Creating polling token for further client communication for device {}, user {}({:?})", @@ -731,11 +819,24 @@ impl EnrollmentServer { info!("Device {} remote configuration done.", device.name); + let openid_provider = OpenIdProvider::get_current(&self.pool) + .await + .map_err(|err| { + error!("Failed to get OpenID provider: {err}"); + Status::internal(format!("unexpected error: {err}")) + })?; + let response = DeviceConfigResponse { device: Some(device.clone().into()), configs: configs.into_iter().map(Into::into).collect(), instance: Some( - InstanceInfo::new(settings, &user.username, &enterprise_settings).into(), + InstanceInfo::new( + settings, + &user.username, + &enterprise_settings, + openid_provider, + ) + .into(), ), token: Some(token.token), }; @@ -760,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); @@ -769,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 { @@ -792,6 +1029,7 @@ impl InitialUserInfo { let enrolled = user.is_enrolled(); let devices = user.user_devices(pool).await?; let device_names = devices.into_iter().map(|dev| dev.device.name).collect(); + let is_admin = user.is_admin(pool).await?; Ok(Self { first_name: user.first_name, last_name: user.last_name, @@ -801,12 +1039,15 @@ impl InitialUserInfo { is_active: user.is_active, device_names, enrolled, + is_admin, }) } } impl From for ProtoDeviceConfig { fn from(config: DeviceConfig) -> Self { + // DEPRECATED(1.5): superseeded by location_mfa_mode + let mfa_enabled = config.location_mfa_mode == LocationMfaMode::Internal; Self { network_id: config.network_id, network_name: config.network_name, @@ -816,8 +1057,13 @@ impl From for ProtoDeviceConfig { pubkey: config.pubkey, allowed_ips: config.allowed_ips.as_csv(), dns: config.dns, - mfa_enabled: config.mfa_enabled, keepalive_interval: config.keepalive_interval, + #[allow(deprecated)] + mfa_enabled, + location_mfa_mode: Some( + >::into(config.location_mfa_mode) + .into(), + ), } } } diff --git a/crates/defguard_core/src/grpc/gateway/client_state.rs b/crates/defguard_core/src/grpc/gateway/client_state.rs index 50309c87ef..76b5439306 100644 --- a/crates/defguard_core/src/grpc/gateway/client_state.rs +++ b/crates/defguard_core/src/grpc/gateway/client_state.rs @@ -5,7 +5,7 @@ use thiserror::Error; use tonic::{Code, Status}; use crate::{ - db::{models::wireguard_peer_stats::WireguardPeerStats, Device, Id, User}, + db::{Device, Id, User, WireguardNetwork, models::wireguard_peer_stats::WireguardPeerStats}, events::GrpcRequestContext, }; @@ -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( @@ -157,16 +158,19 @@ impl ClientMap { /// Returns a list of devices. pub fn disconnect_inactive_vpn_clients_for_location( &mut self, - location_id: Id, - peer_disconnect_threshold_secs: i32, + location: &WireguardNetwork, ) -> Result, GrpcRequestContext)>, ClientMapError> { - debug!("Disconnecting inactive VPN clients for location {location_id}"); + debug!( + "Disconnecting inactive VPN clients for location {}", + location.id + ); + let peer_disconnect_threshold_secs = location.peer_disconnect_threshold; // initialize result let mut disconnected_clients = Vec::new(); // get client state map for given location - if let Some(location_map) = self.0.get_mut(&location_id) { + if let Some(location_map) = self.0.get_mut(&location.id) { let disconnect_threshold = TimeDelta::seconds(peer_disconnect_threshold_secs.into()); // remove clients which have been inactive longer than given location's `peer_disconnect_threshold` @@ -180,16 +184,22 @@ impl ClientMap { client_state.endpoint.ip(), client_state.device.id, client_state.device.name.clone(), + location.clone() ); disconnected_clients .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 bbef2d7759..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,33 +7,40 @@ use std::{ use chrono::{DateTime, TimeDelta, Utc}; use client_state::ClientMap; -use sqlx::{query, Error as SqlxError, PgExecutor, PgPool}; +use defguard_version::version_info_from_metadata; +use semver::Version; +use sqlx::{Error as SqlxError, PgExecutor, PgPool, query}; use thiserror::Error; use tokio::{ sync::{ broadcast::{Receiver as BroadcastReceiver, Sender}, - mpsc::{self, error::SendError, Receiver, UnboundedSender}, + mpsc::{self, Receiver, UnboundedSender, error::SendError}, }, task::JoinHandle, - time::{interval, Duration}, + time::{Duration, interval}, }; use tokio_stream::Stream; -use tonic::{metadata::MetadataMap, Code, Request, Response, Status}; +use tonic::{Code, Request, Response, Status, metadata::MetadataMap}; -use super::{proto::enterprise::firewall::FirewallConfig, GatewayMap}; +use self::map::GatewayMap; +use super::proto::enterprise::firewall::FirewallConfig; pub use crate::grpc::proto::gateway::{ - gateway_service_server, stats_update, update, Configuration, ConfigurationRequest, Peer, - PeerStats, StatsUpdate, Update, + Configuration, ConfigurationRequest, Peer, PeerStats, StatsUpdate, Update, + gateway_service_server, stats_update, update, }; use crate::{ db::{ - models::{wireguard::WireguardNetwork, wireguard_peer_stats::WireguardPeerStats}, Device, GatewayEvent, Id, NoId, User, + models::{wireguard::WireguardNetwork, wireguard_peer_stats::WireguardPeerStats}, }, events::{GrpcEvent, GrpcRequestContext}, 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 @@ -57,8 +63,8 @@ pub fn send_multiple_wireguard_events(events: Vec, wg_tx: &Sender< } } -#[derive(Debug, Error)] #[allow(clippy::large_enum_variant)] +#[derive(Debug, Error)] pub enum GatewayServerError { #[error("Failed to acquire lock on VPN client state map")] ClientStateMutexError, @@ -106,7 +112,7 @@ impl WireguardNetwork { AND u.is_active = true \ ORDER BY d.id ASC", self.id, - self.mfa_enabled + self.mfa_enabled() ) .fetch_all(executor) .await?; @@ -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( @@ -393,10 +425,11 @@ impl GatewayUpdatesHandler { .find(|info| info.network_id == self.network_id) { Some(network_info) => { - if self.network.mfa_enabled && !network_info.is_authorized { - debug!("Created WireGuard device {} is not authorized to connect to MFA enabled location {}", - device.device.name, self.network.name - ); + if self.network.mfa_enabled() && !network_info.is_authorized { + debug!( + "Created WireGuard device {} is not authorized to connect to MFA enabled location {}", + device.device.name, self.network.name + ); continue; } self.send_peer_update( @@ -427,10 +460,11 @@ impl GatewayUpdatesHandler { .find(|info| info.network_id == self.network_id) { Some(network_info) => { - if self.network.mfa_enabled && !network_info.is_authorized { - debug!("Modified WireGuard device {} is not authorized to connect to MFA enabled location {}", - device.device.name, self.network.name - ); + if self.network.mfa_enabled() && !network_info.is_authorized { + debug!( + "Modified WireGuard device {} is not authorized to connect to MFA enabled location {}", + device.device.name, self.network.name + ); continue; } self.send_peer_update( @@ -515,11 +549,7 @@ impl GatewayUpdatesHandler { { let msg = format!( "Failed to send network update, network {network}, update type: {update_type} ({}), error: {err}", - if update_type == 0 { - "CREATE" - } else { - "MODIFY" - }, + if update_type == 0 { "CREATE" } else { "MODIFY" }, ); error!(msg); return Err(Status::new(Code::Internal, msg)); @@ -574,11 +604,7 @@ impl GatewayUpdatesHandler { let msg = format!( "Failed to send peer update for network {}, update type: {update_type} ({}), error: {err}", self.network, - if update_type == 0 { - "CREATE" - } else { - "MODIFY" - }, + if update_type == 0 { "CREATE" } else { "MODIFY" }, ); error!(msg); return Err(Status::new(Code::Internal, msg)); @@ -727,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? { @@ -742,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?; @@ -752,9 +786,7 @@ impl gateway_service_server::GatewayService for GatewayServer { let mut client_map = self.get_client_state_guard()?; // disconnect inactive clients - client_map.disconnect_inactive_vpn_clients_for_location( - network_id, - location.peer_disconnect_threshold, + client_map.disconnect_inactive_vpn_clients_for_location(&location )? }; @@ -787,7 +819,6 @@ impl gateway_service_server::GatewayService for GatewayServer { // TODO: cache usernames since they don't change let user = self.fetch_user_from_db(device.user_id, &public_key).await?; let location = self.fetch_location_from_db(network_id).await?; - let peer_disconnect_threshold = location.peer_disconnect_threshold; // convert stats to DB storage format let stats = WireguardPeerStats::from_peer_stats(peer_stats, network_id, device_id); @@ -830,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,6 +876,7 @@ impl gateway_service_server::GatewayService for GatewayServer { socket_addr.ip(), device.id, device.name.clone(), + location.clone(), ); self.emit_event(GrpcEvent::ClientConnected { context, @@ -853,13 +885,10 @@ impl gateway_service_server::GatewayService for GatewayServer { })?; } } - }; + } // disconnect inactive clients - client_map.disconnect_inactive_vpn_clients_for_location( - network_id, - peer_disconnect_threshold, - )? + client_map.disconnect_inactive_vpn_clients_for_location(&location)? }; // emit client disconnect events @@ -895,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}"); @@ -930,6 +968,7 @@ impl gateway_service_server::GatewayService for GatewayServer { hostname, request.into_inner().name, self.mail_tx.clone(), + version, ); } @@ -967,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"), )); }; @@ -992,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 6d19d7db42..51e8a865d9 100644 --- a/crates/defguard_core/src/grpc/interceptor.rs +++ b/crates/defguard_core/src/grpc/interceptor.rs @@ -1,6 +1,9 @@ -use tonic::{service::Interceptor, Status}; +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 63dd1784e6..30099b581d 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 openidconnect::{core::CoreAuthenticationFlow, AuthorizationCode, CsrfToken, Nonce, Scope}; +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::{ - transport::{Certificate, ClientTlsConfig, Endpoint, Identity, Server, ServerTlsConfig}, - Code, Status, + 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::{gateway_service_server::GatewayServiceServer, GatewayServer}; +use self::gateway::{GatewayServer, gateway_service_server::GatewayServiceServer}; use self::{ - auth::{auth_service_server::AuthServiceServer, AuthServer}, - desktop_client_mfa::ClientMfaServer, + auth::{AuthServer, auth_service_server::AuthServiceServer}, + client_mfa::ClientMfaServer, enrollment::EnrollmentServer, password_reset::PasswordResetServer, proto::proxy::core_response, @@ -46,33 +51,39 @@ use self::{ interceptor::JwtInterceptor, proto::worker::worker_service_server::WorkerServiceServer, worker::WorkerServer, }; +#[cfg(feature = "wireguard")] +pub use crate::version::MIN_GATEWAY_VERSION; use crate::{ + VERSION, auth::failed_login::FailedLoginMap, db::{ - models::enrollment::{Token, ENROLLMENT_TOKEN_TYPE}, AppEvent, Id, Settings, + models::enrollment::{ENROLLMENT_TOKEN_TYPE, Token}, }, enterprise::{ db::models::{enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider}, directory_sync::sync_user_groups_if_configured, grpc::polling::PollingServer, - handlers::openid_login::{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}; mod auth; -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 +115,15 @@ pub mod proto { } use proto::proxy::{ - core_request, proxy_client::ProxyClient, AuthCallbackResponse, AuthInfoResponse, CoreError, - CoreResponse, + 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, - } - } - - /// 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); - } - } +// gRPC header for passing auth token from clients +pub static AUTHORIZATION_HEADER: &str = "authorization"; - /// 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,345 +136,529 @@ 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) => { - error!("client MFA finish error {err}"); - Some(core_response::Payload::CoreError(err.into())) - } + } + // 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 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 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_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::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 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 + { + 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, - CsrfToken::new_random, - 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()), + } + 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, ); - 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, - })) + } 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 data = IncompatibleProxyData::new(version); + data.insert(&incompatible_components); + + // Sleep before trying to reconnect + sleep(TEN_SECS).await; + continue; } + + 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?; } } @@ -816,52 +668,113 @@ 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), + GatewayServer::new( + pool, + gateway_state, + client_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(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 own_version = Version::parse(VERSION)?; + router.add_service( + ServiceBuilder::new() + .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")] @@ -915,6 +828,7 @@ pub struct InstanceInfo { username: String, disable_all_traffic: bool, enterprise_enabled: bool, + openid_display_name: Option, } impl InstanceInfo { @@ -922,8 +836,13 @@ impl InstanceInfo { settings: Settings, username: S, enterprise_settings: &EnterpriseSettings, + openid_provider: Option>, ) -> Self { let config = server_config(); + let openid_display_name = openid_provider + .as_ref() + .map(|provider| provider.display_name.clone()) + .unwrap_or_default(); InstanceInfo { id: settings.uuid, name: settings.instance_name, @@ -932,6 +851,7 @@ impl InstanceInfo { username: username.into(), disable_all_traffic: enterprise_settings.disable_all_traffic, enterprise_enabled: is_enterprise_enabled(), + openid_display_name, } } } @@ -946,6 +866,7 @@ impl From for proto::proxy::InstanceInfo { username: instance.username, disable_all_traffic: instance.disable_all_traffic, enterprise_enabled: instance.enterprise_enabled, + openid_display_name: instance.openid_display_name, } } } diff --git a/crates/defguard_core/src/grpc/password_reset.rs b/crates/defguard_core/src/grpc/password_reset.rs index 987716c247..6af863a8e8 100644 --- a/crates/defguard_core/src/grpc/password_reset.rs +++ b/crates/defguard_core/src/grpc/password_reset.rs @@ -1,5 +1,5 @@ use sqlx::PgPool; -use tokio::sync::mpsc::{error::SendError, UnboundedSender}; +use tokio::sync::mpsc::{UnboundedSender, error::SendError}; use tonic::Status; use super::proto::proxy::{ @@ -8,8 +8,8 @@ use super::proto::proxy::{ }; use crate::{ db::{ - models::enrollment::{Token, PASSWORD_RESET_TOKEN_TYPE}, User, + models::enrollment::{PASSWORD_RESET_TOKEN_TYPE, Token}, }, enterprise::ldap::utils::ldap_change_password, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, PasswordResetEvent}, @@ -83,7 +83,7 @@ impl PasswordResetServer { ) -> Result<(), SendError> { let event = BidiStreamEvent { context, - event: BidiStreamEventType::PasswordReset(event), + event: BidiStreamEventType::PasswordReset(Box::new(event)), }; self.bidi_event_tx.send(event) diff --git a/crates/defguard_core/src/grpc/utils.rs b/crates/defguard_core/src/grpc/utils.rs index 6773513cca..226abbb117 100644 --- a/crates/defguard_core/src/grpc/utils.rs +++ b/crates/defguard_core/src/grpc/utils.rs @@ -4,20 +4,23 @@ use sqlx::PgPool; use tonic::Status; use super::{ - proto::proxy::{DeviceConfig as ProtoDeviceConfig, DeviceConfigResponse, DeviceInfo}, InstanceInfo, + proto::proxy::{DeviceConfig as ProtoDeviceConfig, DeviceConfigResponse, DeviceInfo}, }; use crate::{ + AsCsv, db::{ + Device, Id, Settings, User, models::{ device::{DeviceType, WireguardNetworkDevice}, polling_token::PollingToken, - wireguard::WireguardNetwork, + wireguard::{LocationMfaMode, WireguardNetwork}, }, - Device, Id, Settings, User, }, - enterprise::db::models::enterprise_settings::EnterpriseSettings, - AsCsv, + enterprise::db::models::{ + enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider, + }, + grpc::proto::proxy::LocationMfaMode as ProtoLocationMfaMode, }; // Create a new token for configuration polling. @@ -69,6 +72,11 @@ pub(crate) async fn build_device_config_response( ) -> Result { let settings = Settings::get_current_settings(); + let openid_provider = OpenIdProvider::get_current(pool).await.map_err(|err| { + error!("Failed to get OpenID provider: {err}"); + Status::internal(format!("unexpected error: {err}")) + })?; + let networks = WireguardNetwork::all(pool).await.map_err(|err| { error!("Failed to fetch all networks: {err}"); Status::internal(format!("unexpected error: {err}")) @@ -111,6 +119,8 @@ pub(crate) async fn build_device_config_response( ); Status::internal(format!("unexpected error: {err}")) })?; + // DEPRECATED(1.5): superseeded by location_mfa_mode + let mfa_enabled = network.location_mfa_mode == LocationMfaMode::Internal; let config = ProtoDeviceConfig { config: Device::create_config(&network, &wireguard_network_device), network_id: network.id, @@ -120,23 +130,33 @@ pub(crate) async fn build_device_config_response( pubkey: network.pubkey, allowed_ips: network.allowed_ips.as_csv(), dns: network.dns, - mfa_enabled: network.mfa_enabled, keepalive_interval: network.keepalive_interval, + #[allow(deprecated)] + mfa_enabled, + location_mfa_mode: Some( + >::into( + network.location_mfa_mode, + ) + .into(), + ), }; configs.push(config); } } else { for network in networks { - let wireguard_network_device = - WireguardNetworkDevice::find(pool, device.id, network.id) - .await - .map_err(|err| { - error!( + let wireguard_network_device = WireguardNetworkDevice::find( + pool, device.id, network.id, + ) + .await + .map_err(|err| { + error!( "Failed to fetch WireGuard network device for device {} and network {}: {err}", device.id, network.id ); - Status::internal(format!("unexpected error: {err}")) - })?; + Status::internal(format!("unexpected error: {err}")) + })?; + // DEPRECATED(1.5): superseeded by location_mfa_mode + let mfa_enabled = network.location_mfa_mode == LocationMfaMode::Internal; if let Some(wireguard_network_device) = wireguard_network_device { let config = ProtoDeviceConfig { config: Device::create_config(&network, &wireguard_network_device), @@ -147,8 +167,15 @@ pub(crate) async fn build_device_config_response( pubkey: network.pubkey, allowed_ips: network.allowed_ips.as_csv(), dns: network.dns, - mfa_enabled: network.mfa_enabled, keepalive_interval: network.keepalive_interval, + #[allow(deprecated)] + mfa_enabled, + location_mfa_mode: Some( + >::into( + network.location_mfa_mode, + ) + .into(), + ), }; configs.push(config); } @@ -163,7 +190,15 @@ pub(crate) async fn build_device_config_response( Ok(DeviceConfigResponse { device: Some(device.into()), configs, - instance: Some(InstanceInfo::new(settings, &user.username, &enterprise_settings).into()), + instance: Some( + InstanceInfo::new( + settings, + &user.username, + &enterprise_settings, + openid_provider, + ) + .into(), + ), token, }) } diff --git a/crates/defguard_core/src/grpc/worker.rs b/crates/defguard_core/src/grpc/worker.rs index b5a899197a..2b973fbcaf 100644 --- a/crates/defguard_core/src/grpc/worker.rs +++ b/crates/defguard_core/src/grpc/worker.rs @@ -5,7 +5,7 @@ use std::{ time::Instant, }; -use sqlx::{query, PgPool}; +use sqlx::{PgPool, query}; use tokio::sync::mpsc::UnboundedSender; use tonic::{Request, Response, Status}; @@ -13,10 +13,10 @@ use super::{Job, JobResponse, WorkerDetail, WorkerInfo, WorkerState}; pub use crate::grpc::proto::worker::JobStatus; use crate::{ db::{ - models::authentication_key::{AuthenticationKey, AuthenticationKeyType}, AppEvent, HWKeyUserData, User, YubiKey, + models::authentication_key::{AuthenticationKey, AuthenticationKeyType}, }, - grpc::proto::worker::{worker_service_server, GetJobResponse, Worker}, + grpc::proto::worker::{GetJobResponse, Worker, worker_service_server}, }; impl WorkerInfo { diff --git a/crates/defguard_core/src/handlers/activity_log.rs b/crates/defguard_core/src/handlers/activity_log.rs index 211c5ea0c8..c14a68fa88 100644 --- a/crates/defguard_core/src/handlers/activity_log.rs +++ b/crates/defguard_core/src/handlers/activity_log.rs @@ -7,13 +7,13 @@ use ipnetwork::IpNetwork; use sqlx::{FromRow, Postgres, QueryBuilder, Type}; use super::{ - pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationMeta, PaginationParams}, DEFAULT_API_PAGE_SIZE, + pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationMeta, PaginationParams}, }; use crate::{ appstate::AppState, auth::SessionInfo, - db::{models::activity_log::ActivityLogModule, Id}, + db::{Id, models::activity_log::ActivityLogModule}, }; #[derive(Debug, Deserialize, Default)] @@ -22,6 +22,8 @@ pub struct FilterParams { pub until: Option>, #[serde(default = "default_username")] pub username: Vec, + #[serde(default = "default_location")] + pub location: Vec, #[serde(default = "default_event")] pub event: Vec, #[serde(default = "default_module")] @@ -33,6 +35,10 @@ fn default_username() -> Vec { Vec::new() } +fn default_location() -> Vec { + Vec::new() +} + fn default_event() -> Vec { Vec::new() } @@ -56,6 +62,7 @@ pub enum SortKey { #[default] Timestamp, Username, + Location, Ip, Event, Module, @@ -67,6 +74,7 @@ impl Display for SortKey { match self { Self::Timestamp => write!(f, "timestamp"), Self::Username => write!(f, "username"), + Self::Location => write!(f, "location"), Self::Ip => write!(f, "ip"), Self::Event => write!(f, "event"), Self::Module => write!(f, "module"), @@ -99,17 +107,18 @@ pub struct ApiActivityLogEvent { pub timestamp: NaiveDateTime, pub user_id: Id, pub username: String, + pub location: Option, pub ip: IpNetwork, pub event: String, pub module: ActivityLogModule, pub device: String, - pub metadata: Option, + pub description: Option, } // 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 @@ -131,7 +140,7 @@ pub async fn get_activity_log_events( // start with base SELECT query // dummy WHERE filter is use to enable composable filtering let mut query_builder: QueryBuilder = QueryBuilder::new( - "SELECT id, timestamp, user_id, username, ip, event, module, device, metadata FROM activity_log_event WHERE 1=1 ", + "SELECT id, timestamp, user_id, username, location, ip, event, module, device, description FROM activity_log_event WHERE 1=1 ", ); // filter events for non-admin users to show only their own events @@ -150,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 @@ -202,6 +211,14 @@ fn apply_filters(query_builder: &mut QueryBuilder, filters: &FilterPar .push(") "); } + // location filter + if !filters.location.is_empty() { + query_builder + .push(" AND location = ANY(") + .push_bind(filters.location.clone()) + .push(") "); + } + // event filter if !filters.event.is_empty() { query_builder @@ -221,12 +238,14 @@ fn apply_filters(query_builder: &mut QueryBuilder, filters: &FilterPar // search by provided term // following columns are supported: // - username + // - location // - module // - event // - device + // - description if let Some(search_term) = &filters.search { query_builder - .push(" AND CONCAT(username, ' ', module, ' ', event, ' ', device, ' ') ILIKE ") + .push(" AND CONCAT(username, ' ', location, ' ', module, ' ', event, ' ', device, ' ', description, ' ') ILIKE ") .push_bind(format!("%{search_term}%")) .push(" "); } diff --git a/crates/defguard_core/src/handlers/app_info.rs b/crates/defguard_core/src/handlers/app_info.rs index 5e1d00d088..bc67e8c17f 100644 --- a/crates/defguard_core/src/handlers/app_info.rs +++ b/crates/defguard_core/src/handlers/app_info.rs @@ -1,15 +1,17 @@ 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::{get_counts, LimitsExceeded}, + 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 8377d1fd87..4e629ee746 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -1,20 +1,20 @@ use std::net::IpAddr; use axum::{ - extract::{Json, State}, + extract::{Json, Path, State}, http::StatusCode, }; use axum_client_ip::InsecureClientIp; use axum_extra::{ + TypedHeader, extract::{ - cookie::{Cookie, CookieJar, SameSite}, PrivateCookieJar, + cookie::{Cookie, CookieJar, SameSite}, }, headers::UserAgent, - TypedHeader, }; use serde_json::json; -use sqlx::{types::Uuid, PgPool}; +use sqlx::{PgPool, types::Uuid}; use time::Duration; use tokio::sync::mpsc::UnboundedSender; use uaparser::Parser; @@ -23,25 +23,26 @@ use webauthn_rs_proto::options::CollectedClientData; use super::{ ApiResponse, ApiResult, Auth, AuthCode, AuthResponse, AuthTotp, RecoveryCode, RecoveryCodes, - WebAuthnRegistration, SESSION_COOKIE_NAME, + SESSION_COOKIE_NAME, WebAuthnRegistration, }; use crate::{ appstate::AppState, auth::{ - failed_login::{check_failed_logins, log_failed_login_attempt}, SessionInfo, + failed_login::{check_failed_logins, log_failed_login_attempt}, }, db::{Id, MFAInfo, MFAMethod, Session, SessionState, Settings, User, UserInfo, WebAuthn}, enterprise::ldap::utils::login_through_ldap, error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ + SIGN_IN_COOKIE_NAME, mail::{ send_email_mfa_activation_email, send_email_mfa_code_email, send_mfa_configured_email, }, - SIGN_IN_COOKIE_NAME, + user_for_admin_or_self, }, - headers::{check_new_device_login, get_user_agent_device, USER_AGENT_PARSER}, + headers::{USER_AGENT_PARSER, check_new_device_login, get_user_agent_device}, mail::Mail, server_config, }; @@ -136,37 +137,52 @@ 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(); - let mut user = match User::find_by_username(&appstate.pool, &username_or_email).await { - Ok(Some(user)) => match user.verify_password(&data.password) { + // attempt to find user first by username and then by email + let mut conn = appstate.pool.acquire().await?; + 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(err) => { - info!("Failed to authenticate user {username_or_email} through LDAP: {err}"); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); + 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, - insecure_ip, - user_agent.to_string(), + 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}" ), - event: ApiEventType::UserLoginFailed, - })?; - return Err(WebError::Authorization(err.to_string())); + }), + })?; + return Err(WebError::Authorization(ldap_err.to_string())); } } } else { - info!("Failed to authenticate user {username_or_email}: {err}"); - log_failed_login_attempt(&appstate.failed_logins, &username_or_email); + 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, @@ -174,86 +190,30 @@ pub(crate) async fn authenticate( insecure_ip, user_agent.to_string(), ), - event: ApiEventType::UserLoginFailed, + event: Box::new(ApiEventType::UserLoginFailed { + message: format!( + "Authentication for {username_or_email} failed: {err}" + ), + }), })?; return Err(WebError::Authorization(err.to_string())); } } - }, - Ok(None) => { - match User::find_by_email(&appstate.pool, &username_or_email).await { - Ok(Some(user)) => match user.verify_password(&data.password) { - Ok(()) => user, - Err(err) => { - if settings.ldap_enabled { - if let Ok(user) = - login_through_ldap(&appstate.pool, &user.username, &data.password) - .await - { - user - } else { - info!("Failed to authenticate user {username_or_email}: {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: ApiEventType::UserLoginFailed, - })?; - return Err(WebError::Authorization(err.to_string())); - } - } else { - info!("Failed to authenticate user {username_or_email}: {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: ApiEventType::UserLoginFailed, - })?; - return Err(WebError::Authorization(err.to_string())); - } - } - }, - Ok(None) => { - // 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())); - } - } - } - Err(err) => { - error!("DB error when authenticating user {username_or_email}: {err}"); - return Err(WebError::DbError(err.to_string())); - } - } } - Err(err) => { - error!("DB error when authenticating user {username_or_email}: {err}"); - return Err(WebError::DbError(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())); + } } }; + // check if user account is active if !user.is_active { info!("Failed to authenticate user {username_or_email}: user is disabled"); return Err(WebError::Authorization("user not found".into())); @@ -312,7 +272,7 @@ pub(crate) async fn authenticate( insecure_ip, user_agent.to_string(), ), - event: ApiEventType::UserLogin, + event: Box::new(ApiEventType::UserLogin), })?; Ok(( @@ -357,7 +317,7 @@ pub async fn logout( insecure_ip, user_agent.to_string(), ), - event: ApiEventType::UserLogout, + event: Box::new(ApiEventType::UserLogout), })?; Ok((cookies, ApiResponse::default())) @@ -388,7 +348,7 @@ pub async fn mfa_enable( } } -/// Disable MFA +/// Disable own MFA pub async fn mfa_disable( session_info: SessionInfo, context: ApiRequestContext, @@ -399,12 +359,30 @@ pub async fn mfa_disable( user.disable_mfa(&appstate.pool).await?; appstate.emit_event(ApiEvent { context, - event: ApiEventType::MfaDisabled, + event: Box::new(ApiEventType::MfaDisabled), })?; info!("Disabled MFA for user {}", user.username); Ok(ApiResponse::default()) } +/// Disable specific user's MFA +pub async fn disable_user_mfa( + session_info: SessionInfo, + context: ApiRequestContext, + State(appstate): State, + Path(username): Path, +) -> ApiResult { + let mut user = user_for_admin_or_self(&appstate.pool, &session_info, &username).await?; + debug!("Disabling MFA for user {}", user.username); + user.disable_mfa(&appstate.pool).await?; + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::UserMfaDisabled { user }), + })?; + info!("Disabled MFA for user {username}"); + Ok(ApiResponse::default()) +} + /// Initialize WebAuthn registration pub async fn webauthn_init( mut session_info: SessionInfo, @@ -444,6 +422,7 @@ pub async fn webauthn_init( /// Finish WebAuthn registration pub async fn webauthn_finish( session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Json(webauth_reg): Json, ) -> ApiResult { @@ -487,8 +466,9 @@ pub async fn webauthn_finish( .await? .ok_or(WebError::WebauthnRegistration("User not found".into()))?; let recovery_codes = RecoveryCodes::new(user.get_recovery_codes(&appstate.pool).await?); - let webauthn = WebAuthn::new(session.session.user_id, webauth_reg.name, &passkey)?; - webauthn.save(&appstate.pool).await?; + let webauthn = WebAuthn::new(session.session.user_id, webauth_reg.name, &passkey)? + .save(&appstate.pool) + .await?; if user.mfa_method == MFAMethod::None { send_mfa_configured_email( Some(&session.session), @@ -501,6 +481,10 @@ pub async fn webauthn_finish( } info!("Finished Webauthn registration for user {}", user.username); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::MfaSecurityKeyAdded { key: webauthn }), + })?; Ok(ApiResponse { json: json!(recovery_codes), @@ -536,84 +520,93 @@ pub async fn webauthn_end( Json(pubkey): Json, ) -> Result<(PrivateCookieJar, ApiResponse), WebError> { if let Some(passkey_auth) = session.get_passkey_authentication() { - if let Ok(auth_result) = appstate + match appstate .webauthn .finish_passkey_authentication(&pubkey, &passkey_auth) { - if auth_result.needs_update() { - // Find `Passkey` and try to update its credentials - for mut webauthn in WebAuthn::all_for_user(&appstate.pool, session.user_id).await? { - if let Some(true) = webauthn.passkey()?.update_credential(&auth_result) { - webauthn.save(&appstate.pool).await?; + Ok(auth_result) => { + if auth_result.needs_update() { + // Find `Passkey` and try to update its credentials + for mut webauthn in + WebAuthn::all_for_user(&appstate.pool, session.user_id).await? + { + if let Some(true) = webauthn.passkey()?.update_credential(&auth_result) { + webauthn.save(&appstate.pool).await?; + } } } - } - session - .set_state(&appstate.pool, SessionState::MultiFactorVerified) - .await?; - return if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { - let user_info = UserInfo::from_user(&appstate.pool, &user).await?; - appstate.emit_event(ApiEvent { - // User may not be fully authenticated so we can't use - // context extractor in this handler since it requires - // the `SessionInfo` object. - context: ApiRequestContext::new( - user.id, - user.username, - insecure_ip, - user_agent.to_string(), - ), - event: ApiEventType::UserMfaLogin { - mfa_method: MFAMethod::Webauthn, - }, - })?; - - if let Some(openid_cookie) = private_cookies.get(SIGN_IN_COOKIE_NAME) { - debug!("Found OpenID session cookie."); - let redirect_url = openid_cookie.value().to_string(); - let private_cookies = private_cookies.remove(openid_cookie); - Ok(( - private_cookies, - ApiResponse { - json: json!(AuthResponse { - user: user_info, - url: Some(redirect_url), - }), - status: StatusCode::OK, - }, - )) + + session + .set_state(&appstate.pool, SessionState::MultiFactorVerified) + .await?; + + return if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? + { + let user_info = UserInfo::from_user(&appstate.pool, &user).await?; + appstate.emit_event(ApiEvent { + // User may not be fully authenticated so we can't use + // context extractor in this handler since it requires + // the `SessionInfo` object. + context: ApiRequestContext::new( + user.id, + user.username, + insecure_ip, + user_agent.to_string(), + ), + event: Box::new(ApiEventType::UserMfaLogin { + mfa_method: MFAMethod::Webauthn, + }), + })?; + + if let Some(openid_cookie) = private_cookies.get(SIGN_IN_COOKIE_NAME) { + debug!("Found OpenID session cookie."); + let redirect_url = openid_cookie.value().to_string(); + let private_cookies = private_cookies.remove(openid_cookie); + Ok(( + private_cookies, + ApiResponse { + json: json!(AuthResponse { + user: user_info, + url: Some(redirect_url), + }), + status: StatusCode::OK, + }, + )) + } else { + Ok(( + private_cookies, + ApiResponse { + json: json!(AuthResponse { + user: user_info, + url: None, + }), + status: StatusCode::OK, + }, + )) + } } else { - Ok(( - private_cookies, - ApiResponse { - json: json!(AuthResponse { - user: user_info, - url: None, - }), - status: StatusCode::OK, - }, - )) + Ok((private_cookies, ApiResponse::default())) + }; + } + Err(err) => { + // authentication failed, emit relevant event + if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { + appstate.emit_event(ApiEvent { + // User may not be fully authenticated so we can't use + // context extractor in this handler since it requires + // the `SessionInfo` object. + context: ApiRequestContext::new( + user.id, + user.username, + insecure_ip, + user_agent.to_string(), + ), + event: Box::new(ApiEventType::UserMfaLoginFailed { + mfa_method: MFAMethod::Webauthn, + message: format!("Passkey authentication failed: {err}"), + }), + })?; } - } else { - Ok((private_cookies, ApiResponse::default())) - }; - } else { - // authentication failed, emit relevant event - if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { - appstate.emit_event(ApiEvent { - // User may not be fully authenticated so we can't use - // context extractor in this handler since it requires - // the `SessionInfo` object. - context: ApiRequestContext::new( - user.id, - user.username, - insecure_ip, - user_agent.to_string(), - ), - event: ApiEventType::UserMfaLoginFailed { - mfa_method: MFAMethod::Webauthn, - }, - })?; } } } @@ -659,7 +652,7 @@ pub async fn totp_enable( info!("Enabled TOTP for user {}", user.username); appstate.emit_event(ApiEvent { context, - event: ApiEventType::MfaTotpEnabled, + event: Box::new(ApiEventType::MfaTotpEnabled), })?; Ok(ApiResponse { json: json!(recovery_codes), @@ -683,7 +676,7 @@ pub async fn totp_disable( info!("Disabled TOTP for user {}", user.username); appstate.emit_event(ApiEvent { context, - event: ApiEventType::MfaTotpDisabled, + event: Box::new(ApiEventType::MfaTotpDisabled), })?; Ok(ApiResponse::default()) } @@ -699,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 @@ -716,9 +712,9 @@ pub async fn totp_code( insecure_ip, user_agent.to_string(), ), - event: ApiEventType::UserMfaLogin { + event: Box::new(ApiEventType::UserMfaLogin { mfa_method: MFAMethod::OneTimePassword, - }, + }), })?; if let Some(openid_cookie) = private_cookies.get(SIGN_IN_COOKIE_NAME) { debug!("Found openid session cookie."); @@ -747,6 +743,14 @@ pub async fn totp_code( )) } } else { + let message = if user.totp_enabled { + "TOTP code verification failed".to_string() + } else { + 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 @@ -757,9 +761,10 @@ pub async fn totp_code( insecure_ip, user_agent.to_string(), ), - event: ApiEventType::UserMfaLoginFailed { + event: Box::new(ApiEventType::UserMfaLoginFailed { mfa_method: MFAMethod::OneTimePassword, - }, + message, + }), })?; Err(WebError::Authorization("Invalid TOTP code".into())) } @@ -784,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 @@ -891,12 +900,12 @@ pub async fn email_mfa_code( insecure_ip, user_agent.to_string(), ), - event: ApiEventType::UserMfaLogin { + event: Box::new(ApiEventType::UserMfaLogin { mfa_method: MFAMethod::Email, - }, + }), })?; 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(( @@ -922,6 +931,14 @@ pub async fn email_mfa_code( )) } } else { + let message = if user.email_mfa_enabled { + "Email code verification failed".to_string() + } else { + 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 @@ -932,9 +949,10 @@ pub async fn email_mfa_code( insecure_ip, user_agent.to_string(), ), - event: ApiEventType::UserMfaLoginFailed { + event: Box::new(ApiEventType::UserMfaLoginFailed { mfa_method: MFAMethod::Email, - }, + message, + }), })?; Err(WebError::Authorization("Invalid email MFA code".into())) } @@ -974,7 +992,7 @@ pub async fn recovery_code( insecure_ip, user_agent.to_string(), ), - event: ApiEventType::RecoveryCodeUsed, + event: Box::new(ApiEventType::RecoveryCodeUsed), })?; if let Some(openid_cookie) = private_cookies.get(SIGN_IN_COOKIE_NAME) { debug!("Found OpenID session cookie."); diff --git a/crates/defguard_core/src/handlers/forward_auth.rs b/crates/defguard_core/src/handlers/forward_auth.rs index d3a47e1caa..1421932008 100644 --- a/crates/defguard_core/src/handlers/forward_auth.rs +++ b/crates/defguard_core/src/handlers/forward_auth.rs @@ -1,6 +1,6 @@ use axum::{ extract::{FromRequestParts, State}, - http::{header::HeaderValue, request::Parts, StatusCode}, + http::{StatusCode, header::HeaderValue, request::Parts}, response::{IntoResponse, Redirect, Response}, }; use axum_extra::extract::cookie::CookieJar; diff --git a/crates/defguard_core/src/handlers/group.rs b/crates/defguard_core/src/handlers/group.rs index 39f86ba194..192c5e8358 100644 --- a/crates/defguard_core/src/handlers/group.rs +++ b/crates/defguard_core/src/handlers/group.rs @@ -8,17 +8,18 @@ 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}, - db::{models::group::Permission, Group, Id, User, WireguardNetwork}, + db::{Group, Id, User, WireguardNetwork, models::group::Permission}, enterprise::ldap::utils::{ ldap_add_user_to_groups, ldap_add_users_to_groups, ldap_delete_group, ldap_modify_group, ldap_remove_user_from_groups, ldap_remove_users_from_groups, ldap_update_user_state, ldap_update_users_state, }, error::WebError, + events::{ApiEvent, ApiEventType, ApiRequestContext}, hashset, }; @@ -44,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", @@ -66,6 +67,7 @@ pub(crate) struct BulkAssignToGroupsRequest { pub(crate) async fn bulk_assign_to_groups( _role: AdminRole, State(appstate): State, + context: ApiRequestContext, Json(data): Json, ) -> Result { debug!("Assigning groups to users."); @@ -120,9 +122,17 @@ 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 { + context, + event: Box::new(ApiEventType::GroupsBulkAssigned { users, groups }), + })?; Ok(ApiResponse { json: json!({}), @@ -132,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", @@ -162,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, @@ -187,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" = []), @@ -203,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}", @@ -238,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" = []), @@ -248,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?; @@ -279,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", @@ -294,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" = []), @@ -307,8 +331,9 @@ pub(crate) async fn get_group( pub(crate) async fn create_group( _role: AdminRole, 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(); @@ -331,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) @@ -346,10 +371,18 @@ 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); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::GroupAdded { group }), + })?; Ok(ApiResponse { json: json!(group_info), @@ -359,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}", @@ -382,15 +419,18 @@ pub(crate) async fn create_group( pub(crate) async fn modify_group( _role: AdminRole, State(appstate): State, + 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"); error!(msg); return Err(WebError::ObjectNotFound(msg)); }; + // store group before modifications + let before = group.clone(); let mut add_to_ldap_groups: HashMap<&User, HashSet<&str>> = HashMap::new(); let mut remove_from_ldap_groups: HashMap<&User, HashSet<&str>> = HashMap::new(); @@ -399,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?; } @@ -426,6 +466,7 @@ pub(crate) async fn modify_group( // Modify group members. let mut current_members = group.members(&mut *transaction).await?; + let users_before = current_members.clone(); let mut members = Vec::new(); for username in &group_info.members { if let Some(index) = current_members @@ -443,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) @@ -452,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) @@ -461,7 +502,7 @@ pub(crate) async fn modify_group( } WireguardNetwork::sync_all_networks(&mut transaction, &appstate.wireguard_tx).await?; - + let users_after = group.members(&mut *transaction).await?.clone(); transaction.commit().await?; ldap_add_users_to_groups(add_to_ldap_groups, &appstate.pool).await; @@ -476,16 +517,45 @@ pub(crate) async fn modify_group( .collect::>(); ldap_update_users_state(affected_users, &appstate.pool).await; + let set_users_before: HashSet<_> = users_before.into_iter().collect(); + let set_users_after: HashSet<_> = users_after.into_iter().collect(); + let added: Vec<_> = set_users_after + .difference(&set_users_before) + .cloned() + .collect(); + let removed: Vec<_> = set_users_before + .difference(&set_users_after) + .cloned() + .collect(); + + if !(added.is_empty() && removed.is_empty()) { + appstate.emit_event(ApiEvent { + context: context.clone(), + event: Box::new(ApiEventType::GroupMembersModified { + group: group.clone(), + added, + removed, + }), + })?; + } + info!("Modified group {}", group.name); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::GroupModified { + before, + after: 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}", @@ -505,11 +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 { @@ -524,14 +596,18 @@ pub(crate) async fn delete_group( }); } } - group.delete(&appstate.pool).await?; + group.clone().delete(&appstate.pool).await?; ldap_delete_group(&name, &appstate.pool).await; // sync allowed devices for all locations 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 }), + })?; Ok(ApiResponse::default()) } else { let msg = format!("Failed to find group {name}"); @@ -545,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}", @@ -568,9 +644,10 @@ pub(crate) async fn delete_group( pub(crate) async fn add_group_member( _role: AdminRole, State(appstate): State, + 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); @@ -580,6 +657,10 @@ pub(crate) async fn add_group_member( let mut conn = appstate.pool.acquire().await?; WireguardNetwork::sync_all_networks(&mut conn, &appstate.wireguard_tx).await?; info!("Added user: {} to group: {}", user.username, group.name); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::GroupMemberAdded { group, user }), + })?; Ok(ApiResponse::default()) } else { error!("User not found {}", data.username); @@ -600,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}", @@ -623,8 +704,9 @@ pub(crate) async fn add_group_member( pub(crate) async fn remove_group_member( _role: AdminRole, 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!( @@ -638,6 +720,10 @@ pub(crate) async fn remove_group_member( let mut conn = appstate.pool.acquire().await?; WireguardNetwork::sync_all_networks(&mut conn, &appstate.wireguard_tx).await?; info!("Removed user: {} from group: {}", user.username, group.name); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::GroupMemberRemoved { group, user }), + })?; Ok(ApiResponse { json: json!({}), status: StatusCode::OK, diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index 2a34a259d6..67aa52cddd 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -10,20 +10,20 @@ use reqwest::Url; use serde_json::json; use tokio::{ fs::read_to_string, - sync::mpsc::{unbounded_channel, UnboundedSender}, + sync::mpsc::{UnboundedSender, unbounded_channel}, }; use super::{ApiResponse, ApiResult}; use crate::{ + PgPool, appstate::AppState, auth::{AdminRole, SessionInfo}, - db::{models::enrollment::TokenError, Id, MFAMethod, Session, User}, + db::{Id, MFAMethod, Session, User, models::enrollment::TokenError}, error::WebError, mail::{Attachment, Mail}, server_config, support::dump_config, - templates::{self, support_data_mail, TemplateError, TemplateLocation}, - PgPool, + templates::{self, TemplateError, TemplateLocation, support_data_mail}, }; static TEST_MAIL_SUBJECT: &str = "Defguard email test"; @@ -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 cecc578518..b088716a37 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -1,12 +1,12 @@ use axum::{ + Json, extract::{FromRef, FromRequestParts}, - http::{request::Parts, HeaderName, HeaderValue, StatusCode}, + http::{StatusCode, request::Parts}, response::{IntoResponse, Response}, - Json, }; use axum_client_ip::InsecureClientIp; -use axum_extra::{headers::UserAgent, TypedHeader}; -use serde_json::{json, Value}; +use axum_extra::{TypedHeader, headers::UserAgent}; +use serde_json::{Value, json}; use sqlx::PgPool; use utoipa::ToSchema; use webauthn_rs::prelude::RegisterPublicKeyCredential; @@ -20,7 +20,6 @@ use crate::{ enterprise::{db::models::acl::AclError, license::LicenseError}, error::WebError, events::ApiRequestContext, - VERSION, }; pub(crate) mod activity_log; @@ -74,6 +73,9 @@ impl From for ApiResponse { WebError::ObjectNotFound(msg) => { ApiResponse::new(json!({ "msg": msg }), StatusCode::NOT_FOUND) } + WebError::ObjectAlreadyExists(msg) => { + ApiResponse::new(json!({ "msg": msg }), StatusCode::CONFLICT) + } WebError::Authorization(msg) => { error!(msg); ApiResponse::new(json!({ "msg": msg }), StatusCode::UNAUTHORIZED) @@ -216,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 3405e97491..6f85448f06 100644 --- a/crates/defguard_core/src/handlers/network_devices.rs +++ b/crates/defguard_core/src/handlers/network_devices.rs @@ -14,21 +14,21 @@ use sqlx::PgConnection; use super::{ApiResponse, ApiResult, WebError}; use crate::{ + AsCsv, appstate::AppState, auth::{AdminRole, SessionInfo}, db::{ + Device, GatewayEvent, Id, User, WireguardNetwork, models::{ device::{DeviceConfig, DeviceInfo, DeviceType, WireguardNetworkDevice}, wireguard::NetworkAddressError, }, - Device, GatewayEvent, Id, User, WireguardNetwork, }, enterprise::limits::update_counts, events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::mail::send_new_device_added_email, server_config, templates::TemplateLocation, - AsCsv, }; #[derive(Serialize)] @@ -163,7 +163,9 @@ pub async fn get_network_device( }); } } - error!("Failed to retrieve network device with id: {device_id}, such network device doesn't exist."); + error!( + "Failed to retrieve network device with id: {device_id}, such network device doesn't exist." + ); Err(WebError::ObjectNotFound(format!( "Network device with ID {device_id} not found" ))) @@ -219,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, @@ -226,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( @@ -331,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 @@ -349,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, + }) } } @@ -631,27 +651,25 @@ pub(crate) async fn add_network_device( session.session.device_info.clone().as_deref(), )?; + let result = AddNetworkDeviceResult { + config, + device: NetworkDeviceInfo::from_device(device.clone(), &mut transaction).await?, + }; + + transaction.commit().await?; + info!( "User {} added a new network device {device_name}.", user.username ); appstate.emit_event(ApiEvent { context, - event: ApiEventType::NetworkDeviceAdded { - device_id: device.id, - device_name: device.name.clone(), - location_id: network.id, - location: network.name, - }, + event: Box::new(ApiEventType::NetworkDeviceAdded { + device, + location: network, + }), })?; - let result = AddNetworkDeviceResult { - config, - device: NetworkDeviceInfo::from_device(device, &mut transaction).await?, - }; - - transaction.commit().await?; - Ok(ApiResponse { json: json!(result), status: StatusCode::CREATED, @@ -681,6 +699,8 @@ pub async fn modify_network_device( error!("Failed to update device {device_id}, device not found"); WebError::ObjectNotFound(format!("Device {device_id} not found")) })?; + // store device before modifications + let before = device.clone(); let device_network = device .find_network_device_networks(&mut *transaction) .await? @@ -733,19 +753,18 @@ pub async fn modify_network_device( device_network.name ); } + let network_device_info = + NetworkDeviceInfo::from_device(device.clone(), &mut transaction).await?; + transaction.commit().await?; - let network_device_info = NetworkDeviceInfo::from_device(device, &mut transaction).await?; appstate.emit_event(ApiEvent { context, - event: ApiEventType::NetworkDeviceModified { - device_id: network_device_info.id, - device_name: network_device_info.name.clone(), - location_id: device_network.id, - location: device_network.name, - }, + event: Box::new(ApiEventType::NetworkDeviceModified { + location: device_network, + before, + after: device, + }), })?; - transaction.commit().await?; - Ok(ApiResponse { json: json!(network_device_info), status: StatusCode::OK, diff --git a/crates/defguard_core/src/handlers/openid_clients.rs b/crates/defguard_core/src/handlers/openid_clients.rs index b9d2132ea5..cc6a0bd52e 100644 --- a/crates/defguard_core/src/handlers/openid_clients.rs +++ b/crates/defguard_core/src/handlers/openid_clients.rs @@ -4,31 +4,39 @@ use axum::{ }; use serde_json::json; -use super::{webhooks::ChangeStateData, ApiResponse, ApiResult}; +use super::{ApiResponse, ApiResult, webhooks::ChangeStateData}; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, db::models::{ - oauth2client::{OAuth2Client, OAuth2ClientSafe}, NewOpenIDClient, + oauth2client::{OAuth2Client, OAuth2ClientSafe}, }, + events::{ApiEvent, ApiEventType, ApiRequestContext}, }; pub async fn add_openid_client( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Json(data): Json, ) -> ApiResult { - let client = OAuth2Client::from_new(data).save(&appstate.pool).await?; debug!( "User {} adding OpenID client {}", - session.user.username, client.name + session.user.username, data.name ); + let client = OAuth2Client::from_new(data).save(&appstate.pool).await?; info!( "User {} added OpenID client {}", session.user.username, client.name ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::OpenIdAppAdded { + app: client.clone(), + }), + })?; Ok(ApiResponse { json: json!(client), status: StatusCode::CREATED, @@ -36,9 +44,9 @@ pub async fn add_openid_client( } pub async fn list_openid_clients(_admin: AdminRole, State(appstate): State) -> ApiResult { - let openid_clients = OAuth2Client::all(&appstate.pool).await?; + let clients = OAuth2Client::all(&appstate.pool).await?; Ok(ApiResponse { - json: json!(openid_clients), + json: json!(clients), status: StatusCode::OK, }) } @@ -49,15 +57,15 @@ pub async fn get_openid_client( session: SessionInfo, ) -> ApiResult { match OAuth2Client::find_by_client_id(&appstate.pool, &client_id).await? { - Some(openid_client) => { + Some(client) => { if session.is_admin { Ok(ApiResponse { - json: json!(openid_client), + json: json!(client), status: StatusCode::OK, }) } else { Ok(ApiResponse { - json: json!(OAuth2ClientSafe::from(openid_client)), + json: json!(OAuth2ClientSafe::from(client)), status: StatusCode::OK, }) } @@ -72,6 +80,7 @@ pub async fn get_openid_client( pub async fn change_openid_client( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(client_id): Path, Json(data): Json, @@ -80,17 +89,31 @@ 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? { - Some(mut openid_client) => { - openid_client.name = data.name; - openid_client.redirect_uri = data.redirect_uri; - openid_client.enabled = data.enabled; - openid_client.scope = data.scope; - openid_client.save(&appstate.pool).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(); + client.name = data.name; + client.redirect_uri = data.redirect_uri; + client.enabled = data.enabled; + client.scope = data.scope; + 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, openid_client.name + session.user.username, client.name ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::OpenIdAppModified { + before, + after: client, + }), + })?; StatusCode::OK } None => StatusCode::NOT_FOUND, @@ -104,6 +127,7 @@ pub async fn change_openid_client( pub async fn change_openid_client_state( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(client_id): Path, Json(data): Json, @@ -113,13 +137,20 @@ pub async fn change_openid_client_state( session.user.username ); let status = match OAuth2Client::find_by_client_id(&appstate.pool, &client_id).await? { - Some(mut openid_client) => { - openid_client.enabled = data.enabled; - openid_client.save(&appstate.pool).await?; + Some(mut client) => { + client.enabled = data.enabled; + client.save(&appstate.pool).await?; info!( "User {} updated OpenID client {client_id} ({}) enabled state to {}", - session.user.username, openid_client.name, openid_client.enabled, + session.user.username, client.name, client.enabled, ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::OpenIdAppStateChanged { + enabled: client.enabled, + app: client, + }), + })?; StatusCode::OK } None => StatusCode::NOT_FOUND, @@ -133,6 +164,7 @@ pub async fn change_openid_client_state( pub async fn delete_openid_client( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(client_id): Path, ) -> ApiResult { @@ -141,12 +173,16 @@ pub async fn delete_openid_client( session.user.username ); let status = match OAuth2Client::find_by_client_id(&appstate.pool, &client_id).await? { - Some(openid_client) => { - openid_client.delete(&appstate.pool).await?; + Some(client) => { + client.clone().delete(&appstate.pool).await?; info!( "User {} deleted OpenID client {client_id}", session.user.username ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::OpenIdAppRemoved { app: client }), + })?; StatusCode::OK } None => StatusCode::NOT_FOUND, diff --git a/crates/defguard_core/src/handlers/openid_flow.rs b/crates/defguard_core/src/handlers/openid_flow.rs index 97bbef5d64..dce8b6d569 100644 --- a/crates/defguard_core/src/handlers/openid_flow.rs +++ b/crates/defguard_core/src/handlers/openid_flow.rs @@ -4,18 +4,24 @@ use std::{ }; use axum::{ + Form, extract::{FromRef, OptionalFromRequestParts, Query, State}, http::{ + HeaderMap, HeaderValue, StatusCode, header::{AUTHORIZATION, LOCATION}, request::Parts, - HeaderMap, HeaderValue, StatusCode, }, - Form, }; use axum_extra::extract::cookie::{Cookie, CookieJar, PrivateCookieJar, SameSite}; -use base64::{prelude::BASE64_STANDARD, Engine}; +use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::Utc; use openidconnect::{ + AccessToken, AdditionalClaims, Audience, AuthUrl, AuthorizationCode, + EmptyAdditionalProviderMetadata, EmptyExtraTokenFields, EndUserEmail, EndUserFamilyName, + EndUserGivenName, EndUserName, EndUserPhoneNumber, EndUserUsername, IdToken, IdTokenClaims, + IdTokenFields, IssuerUrl, JsonWebKeySetUrl, LocalizedClaim, Nonce, PkceCodeChallenge, + PkceCodeVerifier, PrivateSigningKey, RefreshToken, ResponseTypes, Scope, StandardClaims, + StandardErrorResponse, StandardTokenResponse, SubjectIdentifier, TokenUrl, UserInfoUrl, core::{ CoreAuthErrorResponseType, CoreClaimName, CoreErrorResponseType, CoreGenderClaim, CoreGrantType, CoreHmacKey, CoreJsonWebKeySet, CoreJweContentEncryptionAlgorithm, @@ -23,12 +29,6 @@ use openidconnect::{ CoreSubjectIdentifierType, CoreTokenType, }, url::Url, - AccessToken, AdditionalClaims, Audience, AuthUrl, AuthorizationCode, - EmptyAdditionalProviderMetadata, EmptyExtraTokenFields, EndUserEmail, EndUserFamilyName, - EndUserGivenName, EndUserName, EndUserPhoneNumber, EndUserUsername, IdToken, IdTokenClaims, - IdTokenFields, IssuerUrl, JsonWebKeySetUrl, LocalizedClaim, Nonce, PkceCodeChallenge, - PkceCodeVerifier, PrivateSigningKey, RefreshToken, ResponseTypes, Scope, StandardClaims, - StandardErrorResponse, StandardTokenResponse, SubjectIdentifier, TokenUrl, UserInfoUrl, }; use serde::{ de::{Deserialize, Deserializer, Error as DeError, Unexpected, Visitor}, @@ -41,38 +41,52 @@ use time::Duration; use super::{ApiResponse, ApiResult, SESSION_COOKIE_NAME}; use crate::{ appstate::AppState, - auth::{AccessUserInfo, SessionInfo}, + auth::{AccessUserInfo, SessionInfo, UserClaims}, db::{ - models::{auth_code::AuthCode, oauth2client::OAuth2Client}, Id, OAuth2AuthorizedApp, OAuth2Token, Session, SessionState, User, + models::{auth_code::AuthCode, oauth2client::OAuth2Client}, }, error::WebError, - handlers::{mail::send_new_device_ocid_login_email, SIGN_IN_COOKIE_NAME}, + handlers::{SIGN_IN_COOKIE_NAME, mail::send_new_device_ocid_login_email}, server_config, }; /// 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 } } @@ -406,7 +420,10 @@ pub async fn authorization( { // If session expired return login if session.expired() { - info!("Session {} for user id {} has expired, redirecting to login", session.id, session.user_id); + info!( + "Session {} for user id {} has expired, redirecting to login", + session.id, session.user_id + ); let _result = session.delete(&appstate.pool).await; Ok(login_redirect(&data, private_cookies)) } else { @@ -827,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(), @@ -863,7 +881,10 @@ pub async fn token( } } } - error!("Can't issue token - authorized app not found for user {}, client {}", user.username, client.name); + error!( + "Can't issue token - authorized app not found for user {}, client {}", + user.username, client.name + ); } else { error!("User id {} not found", auth_code.user_id); } diff --git a/crates/defguard_core/src/handlers/pagination.rs b/crates/defguard_core/src/handlers/pagination.rs index 5133c07249..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::{error::WebError, VERSION}; +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 fad4ca348e..ee7485bca9 100644 --- a/crates/defguard_core/src/handlers/settings.rs +++ b/crates/defguard_core/src/handlers/settings.rs @@ -7,17 +7,18 @@ use struct_patch::Patch; use super::{ApiResponse, ApiResult}; use crate::{ + AppState, auth::{AdminRole, SessionInfo}, db::{ - models::settings::{update_current_settings, SettingsEssentials, SettingsPatch}, Settings, + models::settings::{SettingsEssentials, SettingsPatch, update_current_settings}, }, enterprise::{ - ldap::{sync::SyncStatus, LDAPConnection}, + ldap::{LDAPConnection, sync::SyncStatus}, license::update_cached_license, }, error::WebError, - AppState, + events::{ApiEvent, ApiEventType, ApiRequestContext}, }; static DEFAULT_NAV_LOGO_URL: &str = "/svg/defguard-nav-logo.svg"; @@ -38,25 +39,34 @@ 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( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, - Json(data): Json, + Json(mut data): Json, ) -> ApiResult { debug!("User {} updating settings", session.user.username); + // fetch current settings for event + 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(); + update_current_settings(&appstate.pool, data).await?; info!("User {} updated settings", session.user.username); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::SettingsUpdated { before, after }), + })?; Ok(ApiResponse::default()) } @@ -84,6 +94,7 @@ pub async fn set_default_branding( State(appstate): State, Path(_id): Path, // TODO: check with front-end and remove. session: SessionInfo, + context: ApiRequestContext, ) -> ApiResult { debug!( "User {} restoring default branding settings", @@ -100,6 +111,10 @@ pub async fn set_default_branding( "User {} restored default branding settings", session.user.username ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::SettingsDefaultBrandingRestored), + })?; Ok(ApiResponse { json: json!(settings), status: StatusCode::OK, @@ -113,6 +128,7 @@ pub async fn patch_settings( _admin: AdminRole, State(appstate): State, session: SessionInfo, + context: ApiRequestContext, Json(data): Json, ) -> ApiResult { debug!( @@ -120,6 +136,8 @@ pub async fn patch_settings( session.user.username ); let mut settings = Settings::get_current_settings(); + // prepare clone for emitting an event + let before = settings.clone(); // Handle updating the cached license if let Some(license_key) = &data.license { @@ -147,9 +165,15 @@ pub async fn patch_settings( settings.apply(data); settings.validate()?; + // clone for event + let after = settings.clone(); update_current_settings(&appstate.pool, settings).await?; info!("Admin {} patched settings.", session.user.username); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::SettingsUpdatedPartial { before, after }), + })?; Ok(ApiResponse::default()) } diff --git a/crates/defguard_core/src/handlers/ssh_authorized_keys.rs b/crates/defguard_core/src/handlers/ssh_authorized_keys.rs index ef8075965e..91d925453a 100644 --- a/crates/defguard_core/src/handlers/ssh_authorized_keys.rs +++ b/crates/defguard_core/src/handlers/ssh_authorized_keys.rs @@ -1,21 +1,22 @@ use axum::{ + Json, extract::{Path, Query, State}, http::StatusCode, - Json, }; use serde_json::json; -use sqlx::{query, Error as SqlxError, PgExecutor, PgPool}; +use sqlx::{Error as SqlxError, PgExecutor, PgPool, query}; use ssh_key::PublicKey; -use super::{user_for_admin_or_self, ApiResponse, ApiResult}; +use super::{ApiResponse, ApiResult, user_for_admin_or_self}; use crate::{ appstate::AppState, auth::SessionInfo, db::{ - models::authentication_key::{AuthenticationKey, AuthenticationKeyType}, Group, Id, User, + models::authentication_key::{AuthenticationKey, AuthenticationKeyType}, }, error::WebError, + events::{ApiEvent, ApiEventType, ApiRequestContext}, }; #[derive(Deserialize, Serialize)] @@ -156,6 +157,7 @@ pub struct AddAuthenticationKeyData { pub async fn add_authentication_key( State(appstate): State, session: SessionInfo, + context: ApiRequestContext, Path(username): Path, Json(data): Json, ) -> ApiResult { @@ -195,7 +197,7 @@ pub async fn add_authentication_key( return Err(WebError::BadRequest("Key already exists.".into())); } - AuthenticationKey::new( + let key = AuthenticationKey::new( user.id, trimmed_key.to_string(), Some(data.name.clone()), @@ -209,6 +211,10 @@ pub async fn add_authentication_key( "Added new key \"{}\" of type {:?} for user {username}", data.name, data.key_type ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::AuthenticationKeyAdded { key }), + })?; Ok(ApiResponse { json: json!({}), @@ -234,6 +240,7 @@ pub async fn fetch_authentication_keys( pub async fn delete_authentication_key( State(appstate): State, session: SessionInfo, + context: ApiRequestContext, Path((username, key_id)): Path<(String, i64)>, ) -> ApiResult { let user = user_for_admin_or_self(&appstate.pool, &session, &username).await?; @@ -241,7 +248,15 @@ pub async fn delete_authentication_key( if !session.is_admin && user.id != key.user_id { return Err(WebError::Forbidden(String::new())); } - key.delete(&appstate.pool).await?; + key.clone().delete(&appstate.pool).await?; + info!( + "Removed key \"{:?}\"({}) of type {:?} for user {username}", + key.name, key.id, key.key_type + ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::AuthenticationKeyRemoved { key }), + })?; } else { error!("Key with id {} not found", key_id); return Err(WebError::BadRequest("Key not found".into())); @@ -261,6 +276,7 @@ pub struct RenameRequest { pub async fn rename_authentication_key( State(appstate): State, session: SessionInfo, + context: ApiRequestContext, Path((username, key_id)): Path<(String, i64)>, Json(data): Json, ) -> ApiResult { @@ -280,8 +296,21 @@ pub async fn rename_authentication_key( ); return Err(WebError::Forbidden(String::new())); } + let old_name = key.name.clone(); key.name = Some(data.name); key.save(&appstate.pool).await?; + info!( + "User {} renamed key {:?}({}) of user with id {}", + user.username, key.name, key.id, key.user_id + ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::AuthenticationKeyRenamed { + old_name, + new_name: key.name.clone(), + key, + }), + })?; } else { error!( "User {} tried to rename non-existing key with id {}", diff --git a/crates/defguard_core/src/handlers/support.rs b/crates/defguard_core/src/handlers/support.rs index 549486fca8..83b79ceed0 100644 --- a/crates/defguard_core/src/handlers/support.rs +++ b/crates/defguard_core/src/handlers/support.rs @@ -2,11 +2,11 @@ use axum::{extract::State, http::StatusCode}; use super::{ApiResponse, ApiResult}; use crate::{ + AppState, auth::{AdminRole, SessionInfo}, error::WebError, server_config, support::dump_config, - AppState, }; pub async fn configuration( diff --git a/crates/defguard_core/src/handlers/updates.rs b/crates/defguard_core/src/handlers/updates.rs index 10e9584ae5..c28690f60a 100644 --- a/crates/defguard_core/src/handlers/updates.rs +++ b/crates/defguard_core/src/handlers/updates.rs @@ -1,29 +1,44 @@ -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, }; -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 { + 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 72ebc23fca..909d109439 100644 --- a/crates/defguard_core/src/handlers/user.rs +++ b/crates/defguard_core/src/handlers/user.rs @@ -7,21 +7,22 @@ use axum::{ use serde_json::json; use super::{ - mail::EMAIL_PASSOWRD_RESET_START_SUBJECT, user_for_admin_or_self, AddUserData, ApiResponse, - ApiResult, PasswordChange, PasswordChangeSelf, StartEnrollmentRequest, Username, + AddUserData, ApiResponse, ApiResult, PasswordChange, PasswordChangeSelf, + StartEnrollmentRequest, Username, mail::EMAIL_PASSOWRD_RESET_START_SUBJECT, + user_for_admin_or_self, }; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, db::{ + AppEvent, OAuth2AuthorizedApp, User, UserDetails, UserInfo, WebAuthn, models::{ - enrollment::{Token, PASSWORD_RESET_TOKEN_TYPE}, GroupDiff, + enrollment::{PASSWORD_RESET_TOKEN_TYPE, Token}, }, - AppEvent, OAuth2AuthorizedApp, User, UserDetails, UserInfo, WebAuthn, }, 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, @@ -102,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", @@ -113,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"})), @@ -157,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"})), @@ -237,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!({})), @@ -342,9 +331,7 @@ pub async fn add_user( } appstate.emit_event(ApiEvent { context, - event: ApiEventType::UserAdded { - username: user.username, - }, + event: Box::new(ApiEventType::UserAdded { user }), })?; Ok(ApiResponse { json: json!(&user_info), @@ -358,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", @@ -383,12 +375,13 @@ pub async fn add_user( pub async fn start_enrollment( _role: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(username): Path, Json(data): Json, ) -> ApiResult { debug!( - "User {} has started a new enrollment request.", + "User {} creating enrollment token for user {username}.", session.user.username ); @@ -435,7 +428,7 @@ pub async fn start_enrollment( debug!("Transaction committed."); info!( - "The enrollment process for {} has ended with success.", + "User {} created enrollment token for user {username}.", session.user.username ); debug!( @@ -443,6 +436,10 @@ pub async fn start_enrollment( enrollment_token, config.enrollment_url.to_string() ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::EnrollmentTokenAdded { user }), + })?; Ok(ApiResponse { json: json!({"enrollment_token": enrollment_token, "enrollment_url": config.enrollment_url.to_string()}), @@ -456,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", @@ -479,6 +481,7 @@ pub async fn start_enrollment( )] pub async fn start_remote_desktop_configuration( session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(username): Path, Json(data): Json, @@ -495,7 +498,9 @@ pub async fn start_remote_desktop_configuration( )); } - debug!("Verify that the user from the current session is an admin or only peforms desktop activation for self."); + debug!( + "Verify that the user from the current session is an admin or only peforms desktop activation for self." + ); let user = user_for_admin_or_self(&appstate.pool, &session, &username).await?; debug!("Successfully fetched user data: {user:?}"); @@ -505,7 +510,9 @@ pub async fn start_remote_desktop_configuration( None => user.email.clone(), }; - debug!("Create a new database transaction to save a desktop configuration token into the database."); + debug!( + "Create a new database transaction to save a desktop configuration token into the database." + ); let mut transaction = appstate.pool.begin().await?; debug!( @@ -539,6 +546,10 @@ pub async fn start_remote_desktop_configuration( desktop_configuration_token, config.enrollment_url.to_string() ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::ClientConfigurationTokenAdded { user }), + })?; Ok(ApiResponse { json: json!({"enrollment_token": desktop_configuration_token, "enrollment_url": config.enrollment_url.to_string()}), @@ -552,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 +/// +/// - `WebError` if error occurs /// -/// `Please take notice that if user exists in database, endpoint will return status code 400.` +/// **Please take notice that if user exists in database, endpoint will return status code 400.** #[utoipa::path( post, path = "/api/v1/user/available", @@ -598,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( @@ -630,6 +649,9 @@ pub async fn modify_user( ) -> ApiResult { debug!("User {} updating user {username}", session.user.username); let mut user = user_for_admin_or_self(&appstate.pool, &session, &username).await?; + let groups_before = UserInfo::from_user(&appstate.pool, &user).await?.groups; + // store user before mods + let before = user.clone(); let old_username = user.username.clone(); if let Err(err) = check_username(&user_info.username) { debug!("Username {} rejected: {err}", user_info.username); @@ -638,7 +660,6 @@ pub async fn modify_user( status: StatusCode::BAD_REQUEST, }); } - let status_changing = user_info.is_active != user.is_active; let mut transaction = appstate.pool.begin().await?; @@ -687,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)?; @@ -699,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() { @@ -711,7 +741,7 @@ pub async fn modify_user( group_diff .added .iter() - .map(|g| g.as_str()) + .map(String::as_str) .collect::>(), &appstate.pool, ) @@ -724,7 +754,7 @@ pub async fn modify_user( group_diff .removed .iter() - .map(|g| g.as_str()) + .map(String::as_str) .collect::>(), &appstate.pool, ) @@ -732,29 +762,45 @@ pub async fn modify_user( } } - appstate.trigger_action(AppEvent::UserModified(user_info)); - + appstate.trigger_action(AppEvent::UserModified(user_info.clone())); + let groups_after = user_info.groups.clone(); info!("User {} updated user {username}", session.user.username); + + let set_groups_before: HashSet<_> = groups_before.iter().collect(); + let set_groups_after: HashSet<_> = groups_after.iter().collect(); + + if set_groups_before != set_groups_after { + appstate.emit_event(ApiEvent { + context: context.clone(), + event: Box::new(ApiEventType::UserGroupsModified { + user: user.clone(), + before: groups_before, + after: groups_after, + }), + })?; + } + appstate.emit_event(ApiEvent { context, - event: ApiEventType::UserModified { - username: user.username, - }, + event: Box::new(ApiEventType::UserModified { + before, + after: user, + }), })?; Ok(ApiResponse::default()) } /// Delete user /// -/// Endpoint helps you delete a user, but `you can't delete yourself as a administrator`. +/// Deletes user, however, **you can't delete yourself as an administrator**. /// /// # Returns -/// If erorr 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."), @@ -796,7 +842,8 @@ pub async fn delete_user( } else { None }; - user.delete_and_cleanup(&mut transaction, &appstate.wireguard_tx) + user.clone() + .delete_and_cleanup(&mut transaction, &appstate.wireguard_tx) .await?; appstate.trigger_action(AppEvent::UserDeleted(username.clone())); @@ -809,7 +856,7 @@ pub async fn delete_user( info!("User {} deleted user {}", session.user.username, &username); appstate.emit_event(ApiEvent { context, - event: ApiEventType::UserRemoved { username }, + event: Box::new(ApiEventType::UserRemoved { user }), })?; Ok(ApiResponse::default()) } else { @@ -822,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 erorr occurs, endpoint will return `WebError` object. +/// - `WebError` if error occurs #[utoipa::path( put, path = "/api/v1/user/change_password", @@ -843,6 +892,7 @@ pub async fn delete_user( )] pub async fn change_self_password( session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Json(data): Json, ) -> ApiResult { @@ -869,6 +919,10 @@ pub async fn change_self_password( ldap_change_password(&mut user, &data.new_password, &appstate.pool).await; info!("User {} changed his password.", &user.username); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::PasswordChanged), + })?; Ok(ApiResponse { json: json!({}), @@ -878,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. /// -/// `This endpoint doesn't allow you to change your own password. Go to: /api/v1/user/change_password.` +/// If you want to change your own password please go to: `/api/v1/user/change_password`. /// /// # Returns -/// If erorr 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"})), @@ -907,6 +963,7 @@ pub async fn change_self_password( pub async fn change_password( _role: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(username): Path, Json(data): Json, @@ -949,6 +1006,10 @@ pub async fn change_password( "Admin {} changed password for user {username}", session.user.username ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::PasswordChangedByAdmin { user }), + })?; Ok(ApiResponse::default()) } else { debug!("Can't change password for user {username}, user not found"); @@ -961,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 erorr 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."), @@ -989,6 +1050,7 @@ pub async fn change_password( pub async fn reset_password( _role: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(username): Path, ) -> ApiResult { @@ -1058,6 +1120,10 @@ pub async fn reset_password( "Admin {} reset password for user {username}", session.user.username ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::PasswordReset { user }), + })?; Ok(ApiResponse::default()) } else { debug!("Can't reset password for user {username}, user not found"); @@ -1070,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."), @@ -1095,6 +1161,7 @@ pub async fn reset_password( )] pub async fn delete_security_key( session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path((username, id)): Path<(String, i64)>, ) -> ApiResult { @@ -1105,12 +1172,16 @@ pub async fn delete_security_key( let mut user = user_for_admin_or_self(&appstate.pool, &session, &username).await?; if let Some(webauthn) = WebAuthn::find_by_id(&appstate.pool, id).await? { if webauthn.user_id == user.id { - webauthn.delete(&appstate.pool).await?; + webauthn.clone().delete(&appstate.pool).await?; user.verify_mfa_state(&appstate.pool).await?; info!( "User {} deleted security key {id} for user {username}", session.user.username, ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::MfaSecurityKeyRemoved { key: webauthn }), + })?; Ok(ApiResponse::default()) } else { error!( @@ -1130,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" = []), @@ -1174,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/webhooks.rs b/crates/defguard_core/src/handlers/webhooks.rs index cc6513e36c..d30374f399 100644 --- a/crates/defguard_core/src/handlers/webhooks.rs +++ b/crates/defguard_core/src/handlers/webhooks.rs @@ -9,11 +9,13 @@ use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, db::WebHook, + events::{ApiEvent, ApiEventType, ApiRequestContext}, }; pub async fn add_webhook( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Json(webhookdata): Json, ) -> ApiResult { @@ -21,10 +23,16 @@ pub async fn add_webhook( debug!("User {} adding webhook {url}", session.user.username); let webhook: WebHook = webhookdata.into(); let status = match webhook.save(&appstate.pool).await { - Ok(_) => StatusCode::CREATED, + Ok(webhook) => { + info!("User {} added webhook {url}", session.user.username); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::WebHookAdded { webhook }), + })?; + StatusCode::CREATED + } Err(_) => StatusCode::BAD_REQUEST, }; - info!("User {} added webhook {url}", session.user.username); Ok(ApiResponse { json: json!({}), @@ -62,6 +70,7 @@ pub async fn get_webhook( pub async fn change_webhook( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(id): Path, Json(data): Json, @@ -69,6 +78,8 @@ pub async fn change_webhook( debug!("User {} updating webhook {id}", session.user.username); let status = match WebHook::find_by_id(&appstate.pool, id).await? { Some(mut webhook) => { + // store webhook before modifications + let before = webhook.clone(); webhook.url = data.url; webhook.description = data.description; webhook.token = data.token; @@ -78,11 +89,18 @@ pub async fn change_webhook( webhook.on_user_modified = data.on_user_modified; webhook.on_hwkey_provision = data.on_hwkey_provision; webhook.save(&appstate.pool).await?; + info!("User {} updated webhook {id}", session.user.username); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::WebHookModified { + before, + after: webhook, + }), + })?; StatusCode::OK } None => StatusCode::NOT_FOUND, }; - info!("User {} updated webhook {id}", session.user.username); Ok(ApiResponse { json: json!({}), @@ -93,18 +111,23 @@ pub async fn change_webhook( pub async fn delete_webhook( _admin: AdminRole, State(appstate): State, - Path(id): Path, session: SessionInfo, + context: ApiRequestContext, + Path(id): Path, ) -> ApiResult { debug!("User {} deleting webhook {id}", session.user.username); let status = match WebHook::find_by_id(&appstate.pool, id).await? { Some(webhook) => { - webhook.delete(&appstate.pool).await?; + webhook.clone().delete(&appstate.pool).await?; + info!("User {} deleted webhook {id}", session.user.username); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::WebHookRemoved { webhook }), + })?; StatusCode::OK } None => StatusCode::NOT_FOUND, }; - info!("User {} deleted webhook {id}", session.user.username); Ok(ApiResponse { json: json!({}), status, @@ -119,6 +142,7 @@ pub struct ChangeStateData { pub async fn change_enabled( _admin: AdminRole, session: SessionInfo, + context: ApiRequestContext, State(appstate): State, Path(id): Path, Json(data): Json, @@ -131,14 +155,21 @@ pub async fn change_enabled( Some(mut webhook) => { webhook.enabled = data.enabled; webhook.save(&appstate.pool).await?; + info!( + "User {} changed webhook {id} enabled state to {}", + session.user.username, data.enabled + ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::WebHookStateChanged { + enabled: webhook.enabled, + webhook, + }), + })?; StatusCode::OK } None => StatusCode::NOT_FOUND, }; - info!( - "User {} changed webhook {id} enabled state to {}", - session.user.username, data.enabled - ); Ok(ApiResponse { json: json!({}), status, diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 761008376a..1508bc7cdc 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -6,42 +6,47 @@ use std::{ }; use axum::{ + Extension, extract::{Json, Path, Query, State}, http::StatusCode, - Extension, }; use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; use ipnetwork::IpNetwork; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sqlx::PgPool; use utoipa::ToSchema; use uuid::Uuid; -use super::{device_for_admin_or_self, user_for_admin_or_self, ApiResponse, ApiResult, WebError}; +use super::{ApiResponse, ApiResult, WebError, device_for_admin_or_self, user_for_admin_or_self}; use crate::{ + AsCsv, appstate::AppState, - auth::{AdminRole, Claims, ClaimsType, SessionInfo}, + auth::{AdminRole, SessionInfo}, db::{ + AddDevice, Device, GatewayEvent, Id, WireguardNetwork, models::{ device::{ DeviceConfig, DeviceInfo, DeviceNetworkInfo, DeviceType, ModifyDevice, WireguardNetworkDevice, }, wireguard::{ - networks_stats, DateTimeAggregation, MappedDevice, WireguardDeviceStatsRow, - WireguardNetworkInfo, WireguardNetworkStats, WireguardUserStatsRow, + DateTimeAggregation, LocationMfaMode, MappedDevice, WireguardDeviceStatsRow, + WireguardNetworkInfo, WireguardNetworkStats, WireguardUserStatsRow, networks_stats, }, }, - AddDevice, Device, GatewayEvent, Id, WireguardNetwork, }, - 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, - wg_config::{parse_wireguard_config, ImportedDevice}, - AsCsv, + wg_config::{ImportedDevice, parse_wireguard_config}, }; /// Parse a string with comma-separated IP addresses. @@ -75,11 +80,11 @@ pub struct WireguardNetworkData { pub allowed_ips: Option, pub dns: Option, pub allowed_groups: Vec, - pub mfa_enabled: bool, pub keepalive_interval: i32, pub peer_disconnect_threshold: i32, pub acl_enabled: bool, pub acl_default_allow: bool, + pub location_mfa_mode: LocationMfaMode, } impl WireguardNetworkData { @@ -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", @@ -129,6 +172,7 @@ pub(crate) async fn create_network( _role: AdminRole, State(appstate): State, session: SessionInfo, + context: ApiRequestContext, Json(data): Json, ) -> ApiResult { let network_name = data.name.clone(); @@ -136,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, @@ -144,13 +191,12 @@ pub(crate) async fn create_network( data.endpoint, data.dns, allowed_ips, - data.mfa_enabled, data.keepalive_interval, data.peer_disconnect_threshold, data.acl_enabled, data.acl_default_allow, - ) - .map_err(|_| WebError::Serialization("Invalid network address".into()))?; + data.location_mfa_mode, + ); let mut transaction = appstate.pool.begin().await?; let network = network.save(&mut *transaction).await?; @@ -170,6 +216,13 @@ pub(crate) async fn create_network( "User {} created WireGuard network {network_name}", session.user.username ); + + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::VpnLocationAdded { + location: network.clone(), + }), + })?; update_counts(&appstate.pool).await?; Ok(ApiResponse { @@ -184,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}", @@ -205,13 +266,18 @@ pub(crate) async fn modify_network( Path(network_id): Path, State(appstate): State, session: SessionInfo, + context: ApiRequestContext, Json(data): Json, ) -> ApiResult { debug!( "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(); network.allowed_ips = data.parse_allowed_ips(); network.name = data.name; @@ -222,11 +288,11 @@ pub(crate) async fn modify_network( network.port = data.port; network.dns = data.dns; network.address = parse_address_list(&data.address); - network.mfa_enabled = data.mfa_enabled; network.keepalive_interval = data.keepalive_interval; network.peer_disconnect_threshold = data.peer_disconnect_threshold; network.acl_enabled = data.acl_enabled; network.acl_default_allow = data.acl_default_allow; + network.location_mfa_mode = data.location_mfa_mode; network.save(&mut *transaction).await?; network @@ -250,12 +316,25 @@ pub(crate) async fn modify_network( "User {} updated WireGuard network {network_id}", session.user.username, ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::VpnLocationModified { + before, + after: network.clone(), + }), + })?; Ok(ApiResponse { json: json!(network), status: StatusCode::OK, }) } +/// Delete network +/// +/// # Returns +/// - empty JSON +/// +/// - `WebError` if error occurs #[utoipa::path( delete, path = "/api/v1/network/{network_id}", @@ -276,6 +355,7 @@ pub(crate) async fn delete_network( Path(network_id): Path, State(appstate): State, session: SessionInfo, + context: ApiRequestContext, ) -> ApiResult { debug!( "User {} deleting WireGuard network {network_id}", @@ -290,18 +370,30 @@ pub(crate) async fn delete_network( for device in network_devices { device.delete(&mut *transaction).await?; } - network.delete(&mut *transaction).await?; + network.clone().delete(&mut *transaction).await?; transaction.commit().await?; appstate.send_wireguard_event(GatewayEvent::NetworkDeleted(network_id, network_name)); info!( "User {} deleted WireGuard network {network_id}", session.user.username, ); + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::VpnLocationRemoved { location: network }), + })?; update_counts(&appstate.pool).await?; 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", @@ -348,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}", @@ -421,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, @@ -467,6 +564,7 @@ pub(crate) async fn remove_gateway( pub(crate) async fn import_network( _role: AdminRole, State(appstate): State, + context: ApiRequestContext, Json(data): Json, ) -> ApiResult { debug!("Importing network from config file"); @@ -507,7 +605,12 @@ pub(crate) async fn import_network( transaction.commit().await?; info!("Imported network {network} with {} devices", devices.len()); - + appstate.emit_event(ApiEvent { + context, + event: Box::new(ApiEventType::VpnLocationAdded { + location: network.clone(), + }), + })?; update_counts(&appstate.pool).await?; Ok(ApiResponse { @@ -579,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. @@ -587,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( @@ -608,8 +715,8 @@ pub struct AddDeviceResult { "allowed_ips": ["0.0.0.0:8000"], "pubkey": "pubkey", "dns": "8.8.8.8", - "mfa_enabled": false, - "keepalive_interval": 5 + "keepalive_interval": 5, + "location_mfa_mode": "disabled" } ], "device": { @@ -648,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 ); @@ -712,7 +830,9 @@ pub(crate) async fn add_device( if let Some(firewall_config) = location.try_get_firewall_config(&mut transaction).await? { - debug!("Sending firewall config update for location {location} affected by adding new user {username} devices"); + debug!( + "Sending firewall config update for location {location} affected by adding new user {username} devices" + ); events.push(GatewayEvent::FirewallConfigChanged( location_id, firewall_config, @@ -762,21 +882,20 @@ pub(crate) async fn add_device( "User {} added device {device_name} for user {username}", session.user.username ); - // clone name to be used later - let device_name = device.name.clone(); - let device_id = device.id; - let result = AddDeviceResult { configs, device }; + let result = AddDeviceResult { + configs, + device: device.clone(), + }; update_counts(&appstate.pool).await?; appstate.emit_event(ApiEvent { context, - event: ApiEventType::UserDeviceAdded { - device_id, - owner: username, - device_name, - }, + event: Box::new(ApiEventType::UserDeviceAdded { + device, + owner: user, + }), })?; Ok(ApiResponse { @@ -788,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( @@ -830,7 +952,21 @@ 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(); let networks = WireguardNetwork::all(&appstate.pool).await?; if networks.is_empty() { @@ -844,7 +980,9 @@ pub(crate) async fn modify_device( // check pubkeys for network in &networks { if network.pubkey == data.wireguard_pubkey { - error!("Failed to update device {device_id}, device's pubkey must be different from server's pubkey"); + error!( + "Failed to update device {device_id}, device's pubkey must be different from server's pubkey" + ); return Ok(ApiResponse { json: json!({"msg": "device's pubkey must be different from server's pubkey"}), status: StatusCode::BAD_REQUEST, @@ -856,7 +994,6 @@ pub(crate) async fn modify_device( device.update_from(data); // clone to use later - let device_name = device.name.clone(); device.save(&appstate.pool).await?; @@ -882,14 +1019,14 @@ pub(crate) async fn modify_device( info!("User {} updated device {device_id}", session.user.username); - let owner = device.get_owner(&appstate.pool).await?.username; + let owner = device.get_owner(&appstate.pool).await?; appstate.emit_event(ApiEvent { context, - event: ApiEventType::UserDeviceModified { + event: Box::new(ApiEventType::UserDeviceModified { owner, - device_id: device.id, - device_name, - }, + before, + after: device.clone(), + }), })?; Ok(ApiResponse { @@ -900,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!( @@ -946,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."), @@ -984,12 +1127,8 @@ pub(crate) async fn delete_device( // prepare device info let device_info = DeviceInfo::from_device(&mut *transaction, device.clone()).await?; - // clone to use later - let device_name = device.name.clone(); - let device_type = device.device_type.clone(); - // delete device before firewall config is generated - device.delete(&mut *transaction).await?; + device.clone().delete(&mut *transaction).await?; update_counts(&mut *transaction).await?; @@ -1001,7 +1140,9 @@ pub(crate) async fn delete_device( if let Some(firewall_config) = location.try_get_firewall_config(&mut transaction).await? { - debug!("Sending firewall config update for location {location} affected by deleting user {username} device"); + debug!( + "Sending firewall config update for location {location} affected by deleting user {username} device" + ); events.push(GatewayEvent::FirewallConfigChanged( location.id, firewall_config, @@ -1017,21 +1158,13 @@ pub(crate) async fn delete_device( appstate.send_multiple_wireguard_events(events); // Emit event specific to the device type. - match device_type { + match device.device_type { DeviceType::User => { - let owner = device_info - .device - .get_owner(&mut *transaction) - .await? - .username; + let owner = device_info.device.get_owner(&mut *transaction).await?; appstate.emit_event(ApiEvent { context, - event: ApiEventType::UserDeviceRemoved { - device_name, - owner, - device_id, - }, - })? + event: Box::new(ApiEventType::UserDeviceRemoved { device, owner }), + })?; } DeviceType::Network => { if let Some(network_info) = device_info.network_info.first() { @@ -1041,21 +1174,22 @@ pub(crate) async fn delete_device( if let Some(location) = location { appstate.emit_event(ApiEvent { context, - event: ApiEventType::NetworkDeviceRemoved { - device_id, - device_name, - location_id: location.id, - location: location.name, - }, + event: Box::new(ApiEventType::NetworkDeviceRemoved { device, location }), })?; } else { - error!("Network device {device_name}({device_id}) is assigned to non-existent location {}", network_info.network_id); + error!( + "Network device {}({}) is assigned to non-existent location {}", + device.name, device.id, network_info.network_id + ); } } else { - error!("Network device {device_name}({device_id}) has no network assigned"); + error!( + "Network device {}({}) has no network assigned", + device.name, device.id + ); } } - }; + } transaction.commit().await?; info!("User {username} deleted device {device_id}"); @@ -1064,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", @@ -1100,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 = @@ -1183,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/handlers/yubikey.rs b/crates/defguard_core/src/handlers/yubikey.rs index c8660f7408..8f112ea296 100644 --- a/crates/defguard_core/src/handlers/yubikey.rs +++ b/crates/defguard_core/src/handlers/yubikey.rs @@ -1,11 +1,11 @@ use axum::{ + Json, extract::{Path, State}, http::StatusCode, - Json, }; use serde_json::json; -use super::{user_for_admin_or_self, ApiResponse, ApiResult}; +use super::{ApiResponse, ApiResult, user_for_admin_or_self}; use crate::{appstate::AppState, auth::SessionInfo, db::YubiKey, error::WebError}; pub async fn delete_yubikey( diff --git a/crates/defguard_core/src/headers.rs b/crates/defguard_core/src/headers.rs index 03a0f9b056..c9b1bd796d 100644 --- a/crates/defguard_core/src/headers.rs +++ b/crates/defguard_core/src/headers.rs @@ -1,16 +1,22 @@ use std::{borrow::Borrow, sync::LazyLock}; +use axum::http::{HeaderName, HeaderValue}; use sqlx::PgPool; use tokio::sync::mpsc::UnboundedSender; use uaparser::{Client, Parser, UserAgentParser}; use crate::{ - db::{models::device_login::DeviceLoginEvent, Id, Session, User}, + db::{Id, Session, User, models::device_login::DeviceLoginEvent}, handlers::mail::send_new_device_login_email, mail::Mail, 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 b56b4595a3..68f82136ff 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -3,39 +3,48 @@ #![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}, - serve, Extension, Json, Router, + routing::{delete, get, post, put}, + serve, }; -use db::models::device::DeviceType; +use db::models::{device::DeviceType, wireguard::LocationMfaMode}; +use defguard_version::server::DefguardVersionLayer; use defguard_web_ui::{index, svg, web_asset}; -use enterprise::handlers::{ - acl::{ - apply_acl_aliases, apply_acl_rules, create_acl_alias, create_acl_rule, delete_acl_alias, - delete_acl_rule, get_acl_alias, get_acl_rule, list_acl_aliases, list_acl_rules, - update_acl_alias, update_acl_rule, - }, - activity_log_stream::{ - create_activity_log_stream, delete_activity_log_stream, get_activity_log_stream, - modify_activity_log_stream, +use enterprise::{ + handlers::{ + acl::{ + apply_acl_aliases, apply_acl_rules, create_acl_alias, create_acl_rule, + delete_acl_alias, delete_acl_rule, get_acl_alias, get_acl_rule, list_acl_aliases, + list_acl_rules, update_acl_alias, update_acl_rule, + }, + activity_log_stream::{ + create_activity_log_stream, delete_activity_log_stream, get_activity_log_stream, + modify_activity_log_stream, + }, + api_tokens::{add_api_token, delete_api_token, fetch_api_tokens, rename_api_token}, + check_enterprise_info, + enterprise_settings::{get_enterprise_settings, patch_enterprise_settings}, + openid_login::{auth_callback, get_auth_info}, + openid_providers::{ + add_openid_provider, delete_openid_provider, get_current_openid_provider, + test_dirsync_connection, + }, }, - api_tokens::{add_api_token, delete_api_token, fetch_api_tokens, rename_api_token}, - check_enterprise_info, - enterprise_settings::{get_enterprise_settings, patch_enterprise_settings}, - openid_login::{auth_callback, get_auth_info}, - openid_providers::{ - add_openid_provider, delete_openid_provider, get_current_openid_provider, - test_dirsync_connection, + snat::handlers::{ + create_snat_binding, delete_snat_binding, list_snat_bindings, modify_snat_binding, }, }; use events::ApiEvent; use handlers::{ activity_log::get_activity_log_events, + auth::disable_user_mfa, group::{bulk_assign_to_groups, list_groups_info}, network_devices::{ add_network_device, check_ip_availability, download_network_device_config, @@ -52,20 +61,23 @@ use handlers::{ }; use ipnetwork::IpNetwork; use secrecy::ExposeSecret; +use semver::Version; use sqlx::PgPool; use tokio::{ net::TcpListener, sync::{ broadcast::Sender, mpsc::{UnboundedReceiver, UnboundedSender}, - OnceCell, }, }; -use tower_http::trace::{DefaultOnResponse, TraceLayer}; +use tower_http::{ + set_header::SetResponseHeaderLayer, + trace::{DefaultOnResponse, TraceLayer}, +}; use tracing::Level; use utoipa::{ - openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, Modify, OpenApi, + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, }; use utoipa_swagger_ui::SwaggerUi; @@ -95,9 +107,8 @@ use self::{ auth::{Claims, ClaimsType}, config::{DefGuardConfig, InitVpnLocationArgs}, db::{ - init_db, + AppEvent, Device, GatewayEvent, User, WireguardNetwork, init_db, models::wireguard::{DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL}, - AppEvent, Device, GatewayEvent, User, WireguardNetwork, }, handlers::{ auth::{ @@ -118,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, @@ -134,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, }; @@ -157,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; @@ -167,8 +180,12 @@ extern crate tracing; #[macro_use] extern crate serde; -pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_SHA")); -pub static SERVER_CONFIG: OnceCell = OnceCell::const_new(); +// helper for easier migration handling with a custom `migration` folder location +// 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: OnceLock = OnceLock::new(); pub(crate) fn server_config() -> &'static DefGuardConfig { SERVER_CONFIG @@ -181,22 +198,23 @@ pub(crate) const KEY_LENGTH: usize = 32; mod openapi { use db::{ - models::device::{ModifyDevice, UserDevice}, AddDevice, UserDetails, UserInfo, + models::device::{ModifyDevice, UserDevice}, }; use handlers::{ + ApiResponse, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, + SESSION_COOKIE_NAME, StartEnrollmentRequest, Username, group::{self, BulkAssignToGroupsRequest, Groups}, user, wireguard as device, wireguard as network, wireguard::AddDeviceResult, - ApiResponse, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, - StartEnrollmentRequest, Username, SESSION_COOKIE_NAME, }; use utoipa::{ - openapi::security::{HttpAuthScheme, HttpBuilder}, OpenApi, + openapi::security::{HttpAuthScheme, HttpBuilder}, }; use super::*; + use crate::{enterprise::snat::handlers as snat, error::WebError}; #[derive(OpenApi)] #[openapi( @@ -240,44 +258,61 @@ mod openapi { network::delete_network, network::list_networks, network::network_details, + // /network/{location_id}/snat + snat::list_snat_bindings, + 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 = "nework", description = " -Endpoints that allow to control your networks. + (name = "network", description = " +### 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. + +Available actions: +- list all SNAT bindings +- create new SNAT binding +- modify SNAT binding +- delete SNAT binding + "), ) )] pub struct ApiDoc; @@ -325,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)) @@ -345,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), @@ -395,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), @@ -414,15 +454,18 @@ pub fn build_webapp( "/user/{username}/oauth_app/{oauth2client_id}", delete(delete_authorized_app), ) + .route("/user/{username}/mfa", delete(disable_user_mfa)) // 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)) @@ -430,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 @@ -459,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)), @@ -477,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")] @@ -489,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)), ) @@ -508,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)), ); @@ -528,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), @@ -556,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}", @@ -577,6 +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).post(create_snat_binding), + ) + .route( + "/network/{location_id}/snat/{user_id}", + put(modify_snat_binding).delete(delete_snat_binding), + ) + .route("/outdated", get(outdated_components)) .layer(Extension(gateway_state)), ); @@ -587,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()); @@ -604,6 +685,7 @@ pub fn build_webapp( mail_tx, failed_logins, event_tx, + incompatible_components, )) .layer( TraceLayer::new_for_http() @@ -631,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, @@ -642,9 +725,16 @@ pub async fn run_web_server( pool, failed_logins, event_tx, + Version::parse(VERSION)?, + incompatible_components, ); info!("Started web services"); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), server_config().http_port); + let addr = SocketAddr::new( + server_config() + .http_bind_address + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), + server_config().http_port, + ); let listener = TcpListener::bind(&addr).await?; serve( listener, @@ -698,13 +788,12 @@ pub async fn init_dev_env(config: &DefGuardConfig) { "0.0.0.0".to_string(), None, vec![IpNetwork::new(IpAddr::V4(Ipv4Addr::new(10, 1, 1, 0)), 24).unwrap()], - false, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_DISCONNECT_THRESHOLD, false, false, - ) - .expect("Could not create network"); + LocationMfaMode::Disabled, + ); network.pubkey = "zGMeVGm9HV9I4wSKF9AXmYnnAIhDySyqLMuKpcfIaQo=".to_string(); network.prvkey = "MAk3d5KuB167G88HM7nGYR6ksnPMAOguAg2s5EcPp1M=".to_string(); network @@ -778,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 @@ -797,12 +886,12 @@ pub async fn init_vpn_location( args.endpoint.clone(), args.dns.clone(), args.allowed_ips.clone(), - false, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_DISCONNECT_THRESHOLD, false, false, - )? + LocationMfaMode::Disabled, + ) .save(&mut *transaction) .await?; if network.id != location_id { @@ -826,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( @@ -836,12 +925,12 @@ pub async fn init_vpn_location( args.endpoint.clone(), args.dns.clone(), args.allowed_ips.clone(), - false, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_DISCONNECT_THRESHOLD, false, false, - )? + LocationMfaMode::Disabled, + ) .save(pool) .await? }; diff --git a/crates/defguard_core/src/mail.rs b/crates/defguard_core/src/mail.rs index ae58b150a7..13ed6c904c 100644 --- a/crates/defguard_core/src/mail.rs +++ b/crates/defguard_core/src/mail.rs @@ -1,15 +1,15 @@ use std::time::Duration; use lettre::{ + Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, address::AddressError, - message::{header::ContentType, Mailbox, MultiPart, SinglePart}, + message::{Mailbox, MultiPart, SinglePart, header::ContentType}, transport::smtp::{authentication::Credentials, response::Response}, - Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, }; use thiserror::Error; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; -use crate::db::{models::settings::SmtpEncryption, Settings}; +use crate::db::{Settings, models::settings::SmtpEncryption}; const SMTP_TIMEOUT_SECONDS: u64 = 15; @@ -184,7 +184,9 @@ impl MailHandler { Ok(mailer) => match mailer.send(message).await { Ok(response) => { Self::send_result(result_tx, Ok(response.clone())); - info!("Mail sent successfully to: {to}, subject: {subject}, response: {response:?}"); + info!( + "Mail sent successfully to: {to}, subject: {subject}, response: {response:?}" + ); } Err(err) => { error!("Mail sending failed to: {to}, subject: {subject}, error: {err}"); diff --git a/crates/defguard_core/src/random.rs b/crates/defguard_core/src/random.rs index 3732db766d..30edce4459 100644 --- a/crates/defguard_core/src/random.rs +++ b/crates/defguard_core/src/random.rs @@ -1,4 +1,4 @@ -use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use rand::{Rng, distributions::Alphanumeric, thread_rng}; /// Generate random alphanumeric string. #[must_use] @@ -13,5 +13,5 @@ pub(crate) fn gen_alphanumeric(n: usize) -> String { /// Generate random 20-byte secret for TOTP. #[must_use] pub(crate) fn gen_totp_secret() -> Vec { - thread_rng().gen::<[u8; 20]>().to_vec() + thread_rng().r#gen::<[u8; 20]>().to_vec() } diff --git a/crates/defguard_core/src/secret.rs b/crates/defguard_core/src/secret.rs index 2611312e8f..847c296010 100644 --- a/crates/defguard_core/src/secret.rs +++ b/crates/defguard_core/src/secret.rs @@ -3,9 +3,9 @@ use std::{convert::Infallible, error::Error, str::FromStr}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use sqlx::{ + Decode, Encode, Postgres, Type, encode::IsNull, postgres::{PgArgumentBuffer, PgTypeInfo, PgValueRef}, - Decode, Encode, Postgres, Type, }; /// Wrapper for secrecy `SecretString` struct which implements sqlx traits. diff --git a/crates/defguard_core/src/support.rs b/crates/defguard_core/src/support.rs index d20cdc283d..fd2ccfa037 100644 --- a/crates/defguard_core/src/support.rs +++ b/crates/defguard_core/src/support.rs @@ -1,12 +1,13 @@ use std::{collections::HashMap, fmt::Display}; use serde::Serialize; -use serde_json::{json, value::to_value, Value}; +use serde_json::{Value, json, value::to_value}; use sqlx::PgPool; use crate::{ - db::{models::device::WireguardNetworkDevice, Id, Settings, User, WireguardNetwork}, - server_config, VERSION, + VERSION, + db::{Id, Settings, User, WireguardNetwork, models::device::WireguardNetworkDevice}, + server_config, }; /// Unwraps the result returning a JSON representation of value or error diff --git a/crates/defguard_core/src/templates.rs b/crates/defguard_core/src/templates.rs index 926789861e..0c945ffe47 100644 --- a/crates/defguard_core/src/templates.rs +++ b/crates/defguard_core/src/templates.rs @@ -1,11 +1,15 @@ +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::{ + VERSION, db::{Id, MFAMethod, Session, User}, - server_config, VERSION, + server_config, }; static MAIL_BASE: &str = include_str!("../templates/base.tera"); @@ -32,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 { @@ -41,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)?; @@ -56,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; @@ -217,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)?; @@ -269,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}")); @@ -338,7 +361,7 @@ mod test { use claims::assert_ok; use super::*; - use crate::{config::DefGuardConfig, SERVER_CONFIG}; + use crate::{SERVER_CONFIG, config::DefGuardConfig}; fn get_welcome_context() -> Context { let mut context = Context::new(); @@ -449,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 c930c47b0e..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,24 @@ 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.", - update.version, update.release_date); + warn!( + "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 { info!( "There is a new Defguard version available: {} (Released on {})", diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index fe06982ae6..0e608d1275 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -1,9 +1,9 @@ use std::{collections::HashSet, time::Duration}; -use sqlx::{query_as, PgPool}; +use sqlx::{PgPool, query_as}; use tokio::{ sync::broadcast::Sender, - time::{sleep, Instant}, + time::{Instant, sleep}, }; use tracing::Instrument; @@ -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..b3a74105ff --- /dev/null +++ b/crates/defguard_core/src/version.rs @@ -0,0 +1,165 @@ +use std::{ + collections::HashSet, + sync::{Arc, RwLock}, +}; + +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); + +/// 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); + if !self.is_version_supported(version) { + let msg = match version { + Some(version) => format!("Version {version} not supported"), + None => "Missing version headers".to_string(), + }; + let maybe_hostname = request + .metadata() + .get("hostname") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let data = IncompatibleGatewayData::new(version.cloned(), maybe_hostname); + data.insert(&self.incompatible_components); + return Err(Status::failed_precondition(msg)); + } + + Ok(request) + } +} + +#[derive(Default, Clone, Serialize)] +pub struct IncompatibleComponents { + pub gateways: HashSet, + pub proxy: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +pub struct IncompatibleGatewayData { + pub version: Option, + pub hostname: Option, +} + +impl IncompatibleGatewayData { + pub fn new(version: Option, hostname: Option) -> Self { + Self { version, hostname } + } + + /// 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, PartialEq, Eq, Serialize)] +pub struct IncompatibleProxyData { + pub version: Version, +} + +impl IncompatibleProxyData { + pub fn new(version: Version) -> Self { + Self { version } + } + + /// 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 74179d0620..4dcdc47ff4 100644 --- a/crates/defguard_core/src/wg_config.rs +++ b/crates/defguard_core/src/wg_config.rs @@ -1,18 +1,19 @@ use std::{array::TryFromSliceError, net::IpAddr}; -use base64::{prelude::BASE64_STANDARD, DecodeError, Engine}; +use base64::{DecodeError, Engine, prelude::BASE64_STANDARD}; use ipnetwork::{IpNetwork, IpNetworkError}; use thiserror::Error; use x25519_dalek::{PublicKey, StaticSecret}; use crate::{ + KEY_LENGTH, db::{ + Device, WireguardNetwork, models::wireguard::{ - WireguardNetworkError, DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL, + DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL, LocationMfaMode, + WireguardNetworkError, }, - Device, WireguardNetwork, }, - KEY_LENGTH, }; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -106,12 +107,12 @@ pub(crate) fn parse_wireguard_config( String::new(), dns, allowed_ips, - false, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_DISCONNECT_THRESHOLD, 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 4c24560b88..06be7734d9 100644 --- a/crates/defguard_core/src/wireguard_peer_disconnect.rs +++ b/crates/defguard_core/src/wireguard_peer_disconnect.rs @@ -11,7 +11,7 @@ use std::{ }; use chrono::NaiveDateTime; -use sqlx::{query_as, Error as SqlxError, PgPool}; +use sqlx::{Error as SqlxError, PgPool, query_as}; use thiserror::Error; use tokio::{ sync::{ @@ -23,12 +23,12 @@ use tokio::{ use crate::{ db::{ + Device, GatewayEvent, Id, WireguardNetwork, models::{ device::{DeviceInfo, DeviceNetworkInfo, DeviceType, WireguardNetworkDevice}, error::ModelError, - wireguard::WireguardNetworkError, + wireguard::{LocationMfaMode, WireguardNetworkError}, }, - Device, GatewayEvent, Id, WireguardNetwork, }, events::{InternalEvent, InternalEventContext}, }; @@ -96,9 +96,9 @@ pub async fn run_periodic_peer_disconnect( WireguardNetwork::, "SELECT \ id, name, address, port, pubkey, prvkey, endpoint, dns, allowed_ips, \ - connected_at, mfa_enabled, keepalive_interval, peer_disconnect_threshold, \ - acl_enabled, acl_default_allow \ - FROM wireguard_network WHERE mfa_enabled = true", + 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 != 'disabled'::location_mfa_mode", ) .fetch_all(&pool) .await?; @@ -141,7 +141,9 @@ pub async fn run_periodic_peer_disconnect( if let Some(mut device_network_config) = WireguardNetworkDevice::find(&mut *transaction, device.id, location.id).await? { - info!("Marking device {device} as not authorized to connect to location {location}"); + info!( + "Marking device {device} as not authorized to connect to location {location}" + ); // change `is_authorized` value for device device_network_config.is_authorized = false; // clear `preshared_key` value @@ -170,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(), @@ -180,7 +182,9 @@ pub async fn run_periodic_peer_disconnect( PeerDisconnectError::InternalEventError(err) })?; } else { - error!("Network config for device {device} in location {location} not found. Skipping device..."); + error!( + "Network config for device {device} in location {location} not found. Skipping device..." + ); continue; } 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 97% rename from crates/defguard_core/tests/integration/acl.rs rename to crates/defguard_core/tests/integration/api/acl.rs index 5081cc1445..9bf2ffad6f 100644 --- a/crates/defguard_core/tests/integration/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl.rs @@ -1,8 +1,10 @@ use defguard_core::{ config::DefGuardConfig, db::{ - models::{device::DeviceType, settings::initialize_current_settings}, Device, Group, Id, User, WireguardNetwork, + models::{ + device::DeviceType, settings::initialize_current_settings, wireguard::LocationMfaMode, + }, }, enterprise::{ db::models::acl::{AclAlias, AclRule, AliasKind, AliasState, RuleState}, @@ -12,17 +14,18 @@ use defguard_core::{ handlers::Auth, }; use reqwest::StatusCode; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sqlx::{ - postgres::{PgConnectOptions, PgPoolOptions}, PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, }; use tokio::net::TcpListener; -use crate::common::{ - client::TestClient, exceed_enterprise_limits, init_config, initialize_users, make_base_client, +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") @@ -36,12 +39,6 @@ async fn make_client_v2(pool: PgPool, config: DefGuardConfig) -> TestClient { client } -async fn authenticate(client: &TestClient) { - let auth = Auth::new("admin", "pass123"); - let response = client.post("/api/v1/auth").json(&auth).send().await; - assert_eq!(response.status(), StatusCode::OK); -} - fn make_rule() -> EditAclRule { EditAclRule { name: "rule".to_string(), @@ -140,7 +137,7 @@ async fn test_rule_crud(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; let (client, _) = make_test_client(pool).await; - authenticate(&client).await; + authenticate_admin(&client).await; let rule = make_rule(); @@ -192,7 +189,7 @@ async fn test_rule_enterprise(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; let (client, _) = make_test_client(pool).await; - authenticate(&client).await; + authenticate_admin(&client).await; exceed_enterprise_limits(&client).await; @@ -233,7 +230,7 @@ async fn test_alias_crud(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; let (client, _) = make_test_client(pool).await; - authenticate(&client).await; + authenticate_admin(&client).await; let alias = make_alias(); @@ -287,7 +284,7 @@ async fn test_alias_enterprise(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; let (client, _) = make_test_client(pool).await; - authenticate(&client).await; + authenticate_admin(&client).await; exceed_enterprise_limits(&client).await; @@ -328,7 +325,7 @@ async fn test_empty_strings(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; let (client, _) = make_test_client(pool).await; - authenticate(&client).await; + authenticate_admin(&client).await; // rule let mut rule = make_rule(); @@ -412,7 +409,7 @@ async fn test_related_objects(_: PgPoolOptions, options: PgConnectOptions) { let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; // create related objects // networks @@ -424,13 +421,12 @@ async fn test_related_objects(_: PgPoolOptions, options: PgConnectOptions) { "endpoint1".to_string(), None, Vec::new(), - false, 100, 100, false, false, + LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); @@ -545,7 +541,7 @@ async fn test_invalid_related_objects(_: PgPoolOptions, options: PgConnectOption let pool = setup_pool(options).await; let (client, state) = make_test_client(pool).await; - authenticate(&client).await; + authenticate_admin(&client).await; let rule = make_rule(); let response = client.post("/api/v1/acl/rule").json(&rule).send().await; @@ -648,7 +644,7 @@ async fn test_invalid_data(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; let (client, _) = make_test_client(pool).await; - authenticate(&client).await; + authenticate_admin(&client).await; // invalid port let mut rule = make_rule(); @@ -681,7 +677,7 @@ async fn test_rule_create_modify_state(_: PgPoolOptions, options: PgConnectOptio let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; let rule = make_rule(); @@ -736,7 +732,7 @@ async fn test_rule_delete_state_new(_: PgPoolOptions, options: PgConnectOptions) let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; // test NEW rule deletion let rule = make_rule(); @@ -755,7 +751,7 @@ async fn test_rule_delete_state_applied(_: PgPoolOptions, options: PgConnectOpti let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; // create a location WireguardNetwork::new( @@ -765,13 +761,12 @@ async fn test_rule_delete_state_applied(_: PgPoolOptions, options: PgConnectOpti "endpoint1".to_string(), None, Vec::new(), - false, 100, 100, false, false, + LocationMfaMode::Disabled, ) - .unwrap() .save(&pool) .await .unwrap(); @@ -817,7 +812,7 @@ async fn test_rule_duplication(_: PgPoolOptions, options: PgConnectOptions) { // each modification / deletion of parent rule should remove the child and create a new one let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; let rule = make_rule(); let response = client.post("/api/v1/acl/rule").json(&rule).send().await; @@ -847,7 +842,7 @@ async fn test_rule_application(_: PgPoolOptions, options: PgConnectOptions) { let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; let rule = make_rule(); @@ -939,7 +934,7 @@ async fn test_multiple_rules_application(_: PgPoolOptions, options: PgConnectOpt let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; let rule_1 = make_rule(); let rule_2 = make_rule(); @@ -977,7 +972,7 @@ async fn test_alias_create_modify_state(_: PgPoolOptions, options: PgConnectOpti let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; let alias = make_alias(); @@ -1017,7 +1012,7 @@ async fn test_alias_delete(_: PgPoolOptions, options: PgConnectOptions) { let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; // create alias let alias = make_alias(); @@ -1083,7 +1078,7 @@ async fn test_alias_duplication(_: PgPoolOptions, options: PgConnectOptions) { // each modification of parent alias should remove the child and create a new one let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; let alias = make_alias(); let response = client.post("/api/v1/acl/alias").json(&alias).send().await; @@ -1109,7 +1104,7 @@ async fn test_alias_application(_: PgPoolOptions, options: PgConnectOptions) { let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; // create new alias let alias = make_alias(); @@ -1170,7 +1165,7 @@ async fn test_multiple_aliases_application(_: PgPoolOptions, options: PgConnectO let config = init_config(None); let client = make_client_v2(pool.clone(), config).await; - authenticate(&client).await; + authenticate_admin(&client).await; let alias_1 = make_alias(); let alias_2 = make_alias(); diff --git a/crates/defguard_core/tests/integration/api_tokens.rs b/crates/defguard_core/tests/integration/api/api_tokens.rs similarity index 70% rename from crates/defguard_core/tests/integration/api_tokens.rs rename to crates/defguard_core/tests/integration/api/api_tokens.rs index 0c33c93b09..d707abe0c8 100644 --- a/crates/defguard_core/tests/integration/api_tokens.rs +++ b/crates/defguard_core/tests/integration/api/api_tokens.rs @@ -1,17 +1,19 @@ 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}, }, handlers::Auth, }; -use reqwest::{header::HeaderName, StatusCode}; +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 84% rename from crates/defguard_core/tests/integration/auth.rs rename to crates/defguard_core/tests/integration/api/auth.rs index fac1f63ca3..f7150b9a36 100644 --- a/crates/defguard_core/tests/integration/auth.rs +++ b/crates/defguard_core/tests/integration/api/auth.rs @@ -5,24 +5,24 @@ use claims::{assert_err, assert_ok}; use defguard_core::{ auth::{TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}, db::{ - models::settings::update_current_settings, MFAInfo, MFAMethod, Settings, User, UserDetails, + MFAInfo, MFAMethod, Settings, User, UserDetails, models::settings::update_current_settings, }, handlers::{Auth, AuthCode, AuthResponse, AuthTotp}, }; -use reqwest::{header::USER_AGENT, StatusCode}; +use reqwest::{StatusCode, header::USER_AGENT}; use serde::Deserialize; use serde_json::json; use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, query, }; -use totp_lite::{totp_custom, Sha1}; -use webauthn_authenticator_rs::{prelude::Url, softpasskey::SoftPasskey, WebauthnAuthenticator}; +use totp_lite::{Sha1, totp_custom}; +use webauthn_authenticator_rs::{WebauthnAuthenticator, prelude::Url, softpasskey::SoftPasskey}; use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse}; -use crate::common::{ - fetch_user_details, make_client, make_client_with_db, make_client_with_state, make_test_client, - setup_pool, X_FORWARDED_FOR, +use super::common::{ + X_FORWARDED_FOR, fetch_user_details, make_client, make_client_with_db, make_client_with_state, + make_test_client, setup_pool, }; static SESSION_COOKIE_NAME: &str = "defguard_session"; @@ -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,11 +284,55 @@ 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(); - let code = re.captures(content).unwrap().name("code").unwrap().as_str(); - code + re.captures(content).unwrap().name("code").unwrap().as_str() } #[sqlx::test] @@ -421,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; @@ -716,9 +839,10 @@ async fn test_mfa_method_totp_enabled_mail(_: PgPoolOptions, options: PgConnectO "MFA method TOTP has been activated on your account" ); assert!(mail.content.contains("IP Address: 127.0.0.1")); - assert!(mail - .content - .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari")); + assert!( + mail.content + .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") + ); } #[sqlx::test] @@ -747,9 +871,10 @@ async fn test_new_device_login(_: PgPoolOptions, options: PgConnectOptions) { "Defguard: new device logged in to your account" ); assert!(mail.content.contains("IP Address: 127.0.0.1")); - assert!(mail - .content - .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari")); + assert!( + mail.content + .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") + ); let response = client.post("/api/v1/auth/logout").send().await; assert_eq!(response.status(), StatusCode::OK); @@ -782,9 +907,10 @@ async fn test_new_device_login(_: PgPoolOptions, options: PgConnectOptions) { "Defguard: new device logged in to your account" ); assert!(mail.content.contains("IP Address: 127.0.0.1")); - assert!(mail - .content - .contains("Device type: SM-G930VC, OS: Android 7.0, Chrome Mobile WebView")); + assert!( + mail.content + .contains("Device type: SM-G930VC, OS: Android 7.0, Chrome Mobile WebView") + ); } #[sqlx::test] 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 baabbc9702..61a45135dc 100644 --- a/crates/defguard_core/tests/integration/common/client.rs +++ b/crates/defguard_core/tests/integration/api/common/client.rs @@ -1,15 +1,15 @@ use std::{net::SocketAddr, sync::Arc}; -use axum::{serve, Router}; +use axum::{Router, serve}; use bytes::Bytes; use defguard_core::events::ApiEvent; use reqwest::{ + Body, Client, StatusCode, Url, cookie::{Cookie, Jar}, header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT}, redirect::Policy, - Body, Client, StatusCode, Url, }; -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 63% rename from crates/defguard_core/tests/integration/common/mod.rs rename to crates/defguard_core/tests/integration/api/common/mod.rs index 368508b56e..ea5b3393ab 100644 --- a/crates/defguard_core/tests/integration/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -1,44 +1,38 @@ 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::{ + VERSION, auth::failed_login::FailedLoginMap, build_webapp, config::DefGuardConfig, db::{ - init_db, models::settings::initialize_current_settings, AppEvent, GatewayEvent, Id, NoId, - User, UserDetails, + AppEvent, GatewayEvent, Id, NoId, User, UserDetails, + models::settings::initialize_current_settings, }, - enterprise::license::{set_cached_license, License}, + enterprise::license::{License, set_cached_license}, events::ApiEvent, - grpc::{GatewayMap, WorkerState}, + grpc::{WorkerState, gateway::map::GatewayMap}, handlers::Auth, mail::Mail, - SERVER_CONFIG, }; -use reqwest::{header::HeaderName, StatusCode, Url}; -use secrecy::ExposeSecret; +use reqwest::{StatusCode, header::HeaderName}; +use semver::Version; use serde::de::DeserializeOwned; -use serde_json::{json, Value}; -use sqlx::{ - postgres::{PgConnectOptions, PgPoolOptions}, - query, - types::Uuid, - PgPool, -}; +use serde_json::{Value, json}; +use sqlx::PgPool; use tokio::{ net::TcpListener, sync::{ broadcast::{self, Receiver}, - mpsc::{unbounded_channel, UnboundedReceiver}, + mpsc::{UnboundedReceiver, unbounded_channel}, }, }; 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"); @@ -47,63 +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 init_test_db(config: &DefGuardConfig) -> PgPool { - let opts = PgConnectOptions::new() - .host(&config.database_host) - .port(config.database_port) - .username(&config.database_user) - .password(config.database_password.expose_secret()) - .database(&config.database_name); - let pool = PgPool::connect_with(opts) - .await - .expect("Failed to connect to Postgres"); - let db_name = Uuid::new_v4().to_string(); - query(&format!("CREATE DATABASE \"{db_name}\"")) - .execute(&pool) - .await - .expect("Failed to create test database"); - let pool = init_db( - &config.database_host, - config.database_port, - &db_name, - &config.database_user, - config.database_password.expose_secret(), - ) - .await; - - initialize_users(&pool, config).await; - - pool -} - -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>, @@ -133,12 +70,6 @@ impl ClientState { } } -// Helper function to instantiate pool manually as a workaround for issues with `sqlx::test` macro -// reference: https://github.com/launchbadge/sqlx/issues/2567#issuecomment-2009849261 -pub async fn setup_pool(options: PgConnectOptions) -> PgPool { - PgPoolOptions::new().connect_with(options).await.unwrap() -} - pub(crate) async fn make_base_client( pool: PgPool, config: DefGuardConfig, @@ -160,6 +91,7 @@ pub(crate) async fn make_base_client( // Permanent license None, None, + None, ); set_cached_license(Some(license)); @@ -197,6 +129,8 @@ pub(crate) async fn make_base_client( pool, failed_logins, api_event_tx, + Version::parse(VERSION).unwrap(), + Default::default(), ); ( @@ -205,12 +139,6 @@ pub(crate) async fn make_base_client( ) } -/// Make an instance url based on the listener -fn get_test_url(listener: &TcpListener) -> String { - let port = listener.local_addr().unwrap().port(); - format!("http://localhost:{port}") -} - pub(crate) async fn make_test_client(pool: PgPool) -> (TestClient, ClientState) { let listener = TcpListener::bind("127.0.0.1:0") .await @@ -223,18 +151,6 @@ pub(crate) async fn make_test_client(pool: PgPool) -> (TestClient, ClientState) make_base_client(pool, config, listener).await } -/// Makes a test client with a DEFGUARD_URL set to the random url of the listener. -/// This is useful when the instance's url real url needs to match the one set in the ENV variable. -#[allow(dead_code)] -pub(crate) async fn make_test_client_with_real_url() -> (TestClient, ClientState) { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("Could not bind ephemeral socket"); - let config = init_config(Some(&get_test_url(&listener))); - let pool = init_test_db(&config).await; - make_base_client(pool, config, listener).await -} - pub(crate) async fn fetch_user_details(client: &TestClient, username: &str) -> UserDetails { let response = client.get(format!("/api/v1/user/{username}")).send().await; assert_eq!(response.status(), StatusCode::OK); @@ -256,11 +172,11 @@ pub(crate) async fn exceed_enterprise_limits(client: &TestClient) { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": [], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; @@ -276,11 +192,11 @@ pub(crate) async fn exceed_enterprise_limits(client: &TestClient) { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": [], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; @@ -296,11 +212,11 @@ pub(crate) fn make_network() -> Value { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": [], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" }) } @@ -324,3 +240,9 @@ pub(crate) async fn make_client_with_state(pool: PgPool) -> (TestClient, ClientS let (client, client_state) = make_test_client(pool).await; (client, client_state) } + +pub(crate) async fn authenticate_admin(client: &TestClient) { + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); +} 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 91% rename from crates/defguard_core/tests/integration/forward_auth.rs rename to crates/defguard_core/tests/integration/api/forward_auth.rs index b107858e17..83b97c1ba9 100644 --- a/crates/defguard_core/tests/integration/forward_auth.rs +++ b/crates/defguard_core/tests/integration/api/forward_auth.rs @@ -1,8 +1,8 @@ -use defguard_core::{handlers::Auth, SERVER_CONFIG}; +use defguard_core::{SERVER_CONFIG, handlers::Auth}; use reqwest::StatusCode; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use crate::common::{make_client, setup_pool, X_FORWARDED_HOST, X_FORWARDED_URI}; +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 781aa74157..06d86dbe3b 100644 --- a/crates/defguard_core/tests/integration/oauth.rs +++ b/crates/defguard_core/tests/integration/api/oauth.rs @@ -2,19 +2,19 @@ use std::borrow::Cow; use defguard_core::{ db::{ + Id, OAuth2AuthorizedApp, models::{ - oauth2client::{OAuth2Client, OAuth2ClientSafe}, NewOpenIDClient, + oauth2client::{OAuth2Client, OAuth2ClientSafe}, }, - Id, OAuth2AuthorizedApp, }, handlers::Auth, }; -use reqwest::{header::CONTENT_TYPE, StatusCode, Url}; +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 66% rename from crates/defguard_core/tests/integration/openid.rs rename to crates/defguard_core/tests/integration/api/openid.rs index ff51ba5726..7b701bba14 100644 --- a/crates/defguard_core/tests/integration/openid.rs +++ b/crates/defguard_core/tests/integration/api/openid.rs @@ -4,29 +4,29 @@ use axum::http::header::ToStrError; use claims::assert_err; use defguard_core::{ db::{ - models::{oauth2client::OAuth2Client, NewOpenIDClient}, - Id, + Id, User, + models::{NewOpenIDClient, oauth2client::OAuth2Client}, }, handlers::Auth, }; use openidconnect::{ + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, + EmptyAdditionalClaims, HttpRequest, HttpResponse, IssuerUrl, Nonce, OAuth2TokenResponse, + PkceCodeChallenge, RedirectUrl, Scope, UserInfoClaims, core::{ CoreClient, CoreGenderClaim, CoreProviderMetadata, CoreResponseType, CoreTokenResponse, }, http::Method, - AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, - EmptyAdditionalClaims, HttpRequest, HttpResponse, IssuerUrl, Nonce, OAuth2TokenResponse, - PkceCodeChallenge, RedirectUrl, Scope, UserInfoClaims, }; use reqwest::{ - header::{HeaderName, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}, StatusCode, + header::{AUTHORIZATION, CONTENT_TYPE, HeaderName, USER_AGENT}, }; 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; @@ -678,9 +1011,10 @@ async fn test_openid_flow_new_login_mail(_: PgPoolOptions, options: PgConnectOpt assert_eq!(mail.to, "admin@defguard"); assert_eq!(mail.subject, "New login to Test application with defguard"); assert!(mail.content.contains("IP Address: 127.0.0.1")); - assert!(mail - .content - .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari")); + assert!( + mail.content + .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") + ); let response = client .post(format!( 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 7c4bfab65e..461f8fa840 100644 --- a/crates/defguard_core/tests/integration/openid_login.rs +++ b/crates/defguard_core/tests/integration/api/openid_login.rs @@ -4,7 +4,7 @@ use defguard_core::{ enterprise::{ db::models::openid_provider::{DirectorySyncTarget, DirectorySyncUserBehavior}, handlers::openid_providers::AddProviderData, - license::{set_cached_license, License}, + license::{License, set_cached_license}, }, handlers::Auth, }; @@ -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/api/snat.rs b/crates/defguard_core/tests/integration/api/snat.rs new file mode 100644 index 0000000000..a396041d4c --- /dev/null +++ b/crates/defguard_core/tests/integration/api/snat.rs @@ -0,0 +1,383 @@ +use std::net::IpAddr; + +use defguard_core::{ + db::Id, + enterprise::{ + db::models::snat::UserSnatBinding, + license::{get_cached_license, set_cached_license}, + snat::handlers::{EditUserSnatBinding, NewUserSnatBinding}, + }, + handlers::Auth, +}; +use reqwest::StatusCode; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +use super::common::{ + authenticate_admin, exceed_enterprise_limits, make_network, make_test_client, setup_pool, +}; + +#[sqlx::test] +async fn test_snat_crud(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _) = make_test_client(pool).await; + + // admin login + authenticate_admin(&client).await; + + // create location + let response = client + .post("/api/v1/network") + .json(&make_network()) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // list SNAT bindings (should be empty) + let response = client.get("/api/v1/network/1/snat").send().await; + assert_eq!(response.status(), StatusCode::OK); + let bindings: Vec> = response.json().await; + assert!(bindings.is_empty()); + + // create SNAT binding + let new_binding = NewUserSnatBinding { + user_id: 1, // admin user + public_ip: "192.168.1.100".parse().unwrap(), + }; + let response = client + .post("/api/v1/network/1/snat") + .json(&new_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let created_binding: UserSnatBinding = response.json().await; + assert_eq!(created_binding.user_id, 1); + assert_eq!(created_binding.location_id, 1); + assert_eq!( + created_binding.public_ip, + "192.168.1.100".parse::().unwrap() + ); + + // list SNAT bindings (should have one) + let response = client.get("/api/v1/network/1/snat").send().await; + assert_eq!(response.status(), StatusCode::OK); + let bindings: Vec> = response.json().await; + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].user_id, 1); + assert_eq!( + bindings[0].public_ip, + "192.168.1.100".parse::().unwrap() + ); + + // modify SNAT binding + let edit_binding = EditUserSnatBinding { + public_ip: "192.168.1.200".parse().unwrap(), + }; + let response = client + .put("/api/v1/network/1/snat/1") + .json(&edit_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let updated_binding: UserSnatBinding = response.json().await; + assert_eq!( + updated_binding.public_ip, + "192.168.1.200".parse::().unwrap() + ); + + // verify modification + let response = client.get("/api/v1/network/1/snat").send().await; + assert_eq!(response.status(), StatusCode::OK); + let bindings: Vec> = response.json().await; + assert_eq!(bindings.len(), 1); + assert_eq!( + bindings[0].public_ip, + "192.168.1.200".parse::().unwrap() + ); + + // delete SNAT binding + let response = client.delete("/api/v1/network/1/snat/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + + // verify deletion + let response = client.get("/api/v1/network/1/snat").send().await; + assert_eq!(response.status(), StatusCode::OK); + let bindings: Vec> = response.json().await; + assert!(bindings.is_empty()); +} + +#[sqlx::test] +async fn test_snat_enterprise_required(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _) = make_test_client(pool).await; + + // admin login + authenticate_admin(&client).await; + + 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); + + // unset the license + let license = get_cached_license().clone(); + set_cached_license(None); + + // try to use SNAT API without enterprise license + let new_binding = NewUserSnatBinding { + user_id: 1, + public_ip: "192.168.1.100".parse().unwrap(), + }; + + let response = client.get("/api/v1/network/1/snat").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = client + .post("/api/v1/network/1/snat") + .json(&new_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let edit_binding = EditUserSnatBinding { + public_ip: "192.168.1.200".parse().unwrap(), + }; + let response = client + .put("/api/v1/network/1/snat/1") + .json(&edit_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = client.delete("/api/v1/network/1/snat/1").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // restore valid license and try again + set_cached_license(license); + + let response = client.get("/api/v1/network/1/snat").send().await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client + .post("/api/v1/network/1/snat") + .json(&new_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); +} + +#[sqlx::test] +async fn test_snat_admin_required(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _) = make_test_client(pool).await; + + exceed_enterprise_limits(&client).await; + + // create network as admin + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client + .post("/api/v1/network") + .json(&make_network()) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // login as normal user + let auth = Auth::new("hpotter", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // try to use SNAT API as normal user + let new_binding = NewUserSnatBinding { + user_id: 2, // hpotter user + public_ip: "192.168.1.100".parse().unwrap(), + }; + + let response = client.get("/api/v1/network/1/snat").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = client + .post("/api/v1/network/1/snat") + .json(&new_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let edit_binding = EditUserSnatBinding { + public_ip: "192.168.1.200".parse().unwrap(), + }; + let response = client + .put("/api/v1/network/1/snat/2") + .json(&edit_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = client.delete("/api/v1/network/1/snat/2").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[sqlx::test] +async fn test_snat_validation(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _) = make_test_client(pool).await; + + // admin login + 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); + + // try to create binding for non-existent user + let new_binding = NewUserSnatBinding { + user_id: 999, // non-existent user + public_ip: "192.168.1.100".parse().unwrap(), + }; + let response = client + .post("/api/v1/network/1/snat") + .json(&new_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // try to create binding for non-existent network + let new_binding = NewUserSnatBinding { + user_id: 1, + public_ip: "192.168.1.100".parse().unwrap(), + }; + let response = client + .post("/api/v1/network/999/snat") + .json(&new_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // create valid binding + let response = client + .post("/api/v1/network/1/snat") + .json(&new_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // try to create duplicate binding (same user + location) + let response = client + .post("/api/v1/network/1/snat") + .json(&new_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + + // try to modify non-existent binding + let edit_binding = EditUserSnatBinding { + public_ip: "192.168.1.200".parse().unwrap(), + }; + let response = client + .put("/api/v1/network/1/snat/999") + .json(&edit_binding) + .send() + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // try to delete non-existent binding + let response = client.delete("/api/v1/network/1/snat/999").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[sqlx::test] +async fn test_snat_multiple_bindings(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (client, _) = make_test_client(pool).await; + + // admin login + 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); + + // create multiple SNAT bindings for different users + let binding1 = NewUserSnatBinding { + user_id: 1, // admin + public_ip: "192.168.1.100".parse().unwrap(), + }; + let response = client + .post("/api/v1/network/1/snat") + .json(&binding1) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let binding2 = NewUserSnatBinding { + user_id: 2, // hpotter + public_ip: "192.168.1.101".parse().unwrap(), + }; + let response = client + .post("/api/v1/network/1/snat") + .json(&binding2) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // list all bindings + let response = client.get("/api/v1/network/1/snat").send().await; + assert_eq!(response.status(), StatusCode::OK); + let bindings: Vec> = response.json().await; + assert_eq!(bindings.len(), 2); + + // verify both bindings exist + let admin_binding = bindings.iter().find(|b| b.user_id == 1).unwrap(); + let user_binding = bindings.iter().find(|b| b.user_id == 2).unwrap(); + + assert_eq!( + admin_binding.public_ip, + "192.168.1.100".parse::().unwrap() + ); + assert_eq!( + user_binding.public_ip, + "192.168.1.101".parse::().unwrap() + ); + + // delete one binding + let response = client + .delete(format!("/api/v1/network/1/snat/{}", admin_binding.user_id)) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // verify only one binding remains + let response = client.get("/api/v1/network/1/snat").send().await; + assert_eq!(response.status(), StatusCode::OK); + let bindings: Vec> = response.json().await; + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].user_id, 2); +} diff --git a/crates/defguard_core/tests/integration/user.rs b/crates/defguard_core/tests/integration/api/user.rs similarity index 95% rename from crates/defguard_core/tests/integration/user.rs rename to crates/defguard_core/tests/integration/api/user.rs index 6ba78006f2..a2c6307e59 100644 --- a/crates/defguard_core/tests/integration/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -1,15 +1,15 @@ use defguard_core::{ db::{ - models::{oauth2client::OAuth2Client, NewOpenIDClient}, AddDevice, Id, UserInfo, + models::{NewOpenIDClient, oauth2client::OAuth2Client}, }, handlers::{AddUserData, Auth, PasswordChange, PasswordChangeSelf, Username}, }; -use reqwest::{header::USER_AGENT, StatusCode}; +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; @@ -534,9 +515,10 @@ async fn test_user_add_device(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(mail.to, "admin@defguard"); assert_eq!(mail.subject, "Defguard: new device added to your account"); assert!(mail.content.contains("IP Address: 127.0.0.1")); - assert!(mail - .content - .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari")); + assert!( + mail.content + .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") + ); // log in as normal user let auth = Auth::new("hpotter", "pass123"); @@ -559,9 +541,10 @@ async fn test_user_add_device(_: PgPoolOptions, options: PgConnectOptions) { "Defguard: new device logged in to your account" ); assert!(mail.content.contains("IP Address: 127.0.0.1")); - assert!(mail - .content - .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari")); + assert!( + mail.content + .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") + ); // a device with duplicate pubkey cannot be added let response = client @@ -599,9 +582,10 @@ async fn test_user_add_device(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(mail.to, "h.potter@hogwart.edu.uk"); assert_eq!(mail.subject, "Defguard: new device added to your account"); assert!(mail.content.contains("IP Address: 127.0.0.1")); - assert!(mail - .content - .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari")); + assert!( + mail.content + .contains("Device type: iPhone, OS: iOS 17.1, Mobile Safari") + ); } #[sqlx::test] @@ -618,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) @@ -642,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; @@ -651,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 75% rename from crates/defguard_core/tests/integration/wireguard.rs rename to crates/defguard_core/tests/integration/api/wireguard.rs index d1c8c715bb..becf937611 100644 --- a/crates/defguard_core/tests/integration/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -2,13 +2,21 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use defguard_core::{ db::{ + Device, GatewayEvent, Id, WireguardNetwork, models::{ device::WireguardNetworkDevice, - wireguard::{DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL}, + settings::OpenidUsernameHandling, + wireguard::{ + DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL, LocationMfaMode, + }, }, - Device, GatewayEvent, Id, WireguardNetwork, }, - handlers::{wireguard::WireguardNetworkData, Auth, GroupInfo}, + 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; use matches::assert_matches; @@ -16,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) { @@ -56,11 +66,11 @@ async fn test_network(_: PgPoolOptions, options: PgConnectOptions) { 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()], - mfa_enabled: false, keepalive_interval: DEFAULT_KEEPALIVE_INTERVAL, peer_disconnect_threshold: DEFAULT_DISCONNECT_THRESHOLD, acl_enabled: false, acl_default_allow: false, + location_mfa_mode: LocationMfaMode::Disabled, }; let response = client .put(format!("/api/v1/network/{}", network.id)) @@ -114,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; @@ -296,11 +485,11 @@ async fn test_network_address_reassignment(_: PgPoolOptions, options: PgConnectO "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": [], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" }); let response = client.post("/api/v1/network").json(&network).send().await; assert_eq!(response.status(), StatusCode::CREATED); @@ -364,11 +553,11 @@ async fn test_network_address_reassignment(_: PgPoolOptions, options: PgConnectO "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": [], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" }); let response = client .put(format!("/api/v1/network/{}", network_from_details.id)) 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 94% 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 b94645ea68..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 @@ -2,19 +2,19 @@ use std::net::IpAddr; use claims::assert_err; use defguard_core::{ - db::{models::device::DeviceType, Device, GatewayEvent, Group, Id, User, WireguardNetwork}, - handlers::{wireguard::ImportedNetworkData, Auth}, AsCsv, + db::{Device, GatewayEvent, Group, Id, User, WireguardNetwork, models::device::DeviceType}, + handlers::{Auth, wireguard::ImportedNetworkData}, }; use matches::assert_matches; use reqwest::StatusCode; use serde_json::json; use sqlx::{ - postgres::{PgConnectOptions, PgPoolOptions}, PgPool, + 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>) { @@ -147,11 +147,11 @@ async fn test_create_new_network(_: PgPoolOptions, options: PgConnectOptions) { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": ["allowed group"], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; @@ -193,11 +193,11 @@ async fn test_modify_network(_: PgPoolOptions, options: PgConnectOptions) { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": [], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; @@ -226,11 +226,11 @@ async fn test_modify_network(_: PgPoolOptions, options: PgConnectOptions) { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": ["allowed group"], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; @@ -253,11 +253,11 @@ async fn test_modify_network(_: PgPoolOptions, options: PgConnectOptions) { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": ["allowed group", "not allowed group"], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; @@ -281,11 +281,11 @@ async fn test_modify_network(_: PgPoolOptions, options: PgConnectOptions) { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": ["not allowed group"], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; @@ -308,11 +308,11 @@ async fn test_modify_network(_: PgPoolOptions, options: PgConnectOptions) { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": [], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; @@ -557,11 +557,11 @@ async fn test_modify_user(_: PgPoolOptions, options: PgConnectOptions) { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": ["allowed group"], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; @@ -656,11 +656,11 @@ async fn test_delete_only_allowed_group(_: PgPoolOptions, options: PgConnectOpti "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": ["allowed group"], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" })) .send() .await; 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 66% rename from crates/defguard_core/tests/integration/wireguard_network_devices.rs rename to crates/defguard_core/tests/integration/api/wireguard_network_devices.rs index 193399d214..ae99878995 100644 --- a/crates/defguard_core/tests/integration/wireguard_network_devices.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_devices.rs @@ -2,16 +2,16 @@ use std::{net::IpAddr, str::FromStr}; use defguard_core::{ db::{Device, GatewayEvent, Id, WireguardNetwork}, - handlers::{network_devices::AddNetworkDevice, Auth}, + handlers::{Auth, network_devices::AddNetworkDevice}, }; use ipnetwork::IpNetwork; use matches::assert_matches; use reqwest::StatusCode; use serde::Deserialize; -use serde_json::{json, Value}; +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!({ @@ -22,11 +22,11 @@ fn make_network() -> Value { "allowed_ips": "10.1.1.0/24", "dns": "1.1.1.1", "allowed_groups": [], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" }) } @@ -39,20 +39,25 @@ fn make_second_network() -> Value { "allowed_ips": "10.6.1.0/24", "dns": "1.1.1.1", "allowed_groups": [], - "mfa_enabled": false, "keepalive_interval": 25, - "peer_disconnect_threshold": 180, + "peer_disconnect_threshold": 300, "acl_enabled": false, - "acl_default_allow": false + "acl_default_allow": false, + "location_mfa_mode": "disabled" }) } -#[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 97% rename from crates/defguard_core/tests/integration/wireguard_network_import.rs rename to crates/defguard_core/tests/integration/api/wireguard_network_import.rs index 0b542ddb0a..aad4c8d62c 100644 --- a/crates/defguard_core/tests/integration/wireguard_network_import.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_import.rs @@ -2,13 +2,15 @@ use std::net::IpAddr; use defguard_core::{ db::{ + Device, GatewayEvent, WireguardNetwork, models::{ device::{DeviceType, UserDevice}, - wireguard::{DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL}, + wireguard::{ + DEFAULT_DISCONNECT_THRESHOLD, DEFAULT_KEEPALIVE_INTERVAL, LocationMfaMode, + }, }, - Device, GatewayEvent, WireguardNetwork, }, - handlers::{wireguard::ImportedNetworkData, Auth}, + handlers::{Auth, wireguard::ImportedNetworkData}, }; use matches::assert_matches; use reqwest::StatusCode; @@ -16,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) { @@ -55,13 +57,12 @@ async fn test_config_import(_: PgPoolOptions, options: PgConnectOptions) { String::new(), None, vec![], - false, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_DISCONNECT_THRESHOLD, false, false, - ) - .unwrap(); + LocationMfaMode::Disabled, + ); 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 46ef476409..8a02b5c994 100644 --- a/crates/defguard_core/tests/integration/wireguard_network_stats.rs +++ b/crates/defguard_core/tests/integration/api/wireguard_network_stats.rs @@ -1,6 +1,7 @@ use chrono::{Datelike, Duration, NaiveDate, SubsecRound, Timelike, Utc}; use defguard_core::{ db::{ + Id, NoId, models::{ device::Device, wireguard::{ @@ -9,7 +10,6 @@ use defguard_core::{ }, wireguard_peer_stats::WireguardPeerStats, }, - Id, NoId, }, handlers::Auth, }; @@ -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 98% rename from crates/defguard_core/tests/integration/worker.rs rename to crates/defguard_core/tests/integration/api/worker.rs index c336b14e54..88833a892d 100644 --- a/crates/defguard_core/tests/integration/worker.rs +++ b/crates/defguard_core/tests/integration/api/worker.rs @@ -1,14 +1,14 @@ use defguard_core::{ - grpc::{worker::JobStatus, WorkerDetail}, + grpc::{WorkerDetail, worker::JobStatus}, handlers::{ - worker::{JobData, Jobid}, Auth, + worker::{JobData, Jobid}, }, }; 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 11784c03d1..f85d8d0fa3 100644 --- a/crates/defguard_core/tests/integration/main.rs +++ b/crates/defguard_core/tests/integration/main.rs @@ -1,20 +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 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 new file mode 100644 index 0000000000..1ca6377198 --- /dev/null +++ b/crates/defguard_event_logger/src/description.rs @@ -0,0 +1,310 @@ +//! Event description generation for activity log. +//! +//! This module provides functions to generate human-readable descriptions for various +//! types of events that occur within the system. These descriptions are used to provide usable +//! context about what happened during each event. +//! +//! Each event type has its own description generator function that takes the event data +//! and returns an optional description string. Some events may not require additional +//! description beyond their event type name, in which case `None` is returned. + +use crate::message::{DefguardEvent, EnrollmentEvent, VpnEvent}; + +#[must_use] +pub fn get_defguard_event_description(event: &DefguardEvent) -> Option { + match event { + DefguardEvent::UserLogin => None, + DefguardEvent::UserLoginFailed { message } => { + Some(format!("User login failed with: {message}")) + } + DefguardEvent::UserMfaLogin { mfa_method } => { + Some(format!("User logged in using {mfa_method}")) + } + DefguardEvent::UserMfaLoginFailed { + mfa_method, + message, + } => Some(format!( + "User login using {mfa_method} failed with: {message}" + )), + DefguardEvent::UserLogout => None, + DefguardEvent::RecoveryCodeUsed => None, + DefguardEvent::PasswordChanged => None, + DefguardEvent::MfaDisabled => Some("Disabled own MFA".to_string()), + DefguardEvent::UserMfaDisabled { user } => Some(format!("Disabled MFA for user {user}")), + DefguardEvent::MfaTotpEnabled => Some("User configured TOTP for MFA".to_string()), + DefguardEvent::MfaTotpDisabled => Some("User disabled TOTP for MFA".to_string()), + DefguardEvent::MfaEmailEnabled => Some("User configured email for MFA".to_string()), + DefguardEvent::MfaEmailDisabled => Some("User disabled email for MFA".to_string()), + DefguardEvent::PasswordChangedByAdmin { user } => { + Some(format!("Password for user {user} was changed by an admin")) + } + DefguardEvent::PasswordReset { user } => { + Some(format!("Password for user {user} was reset")) + } + DefguardEvent::MfaSecurityKeyAdded { key } => { + Some(format!("Added MFA security key {}", key.name)) + } + DefguardEvent::MfaSecurityKeyRemoved { key } => { + Some(format!("Removed MFA security key {}", key.name)) + } + DefguardEvent::UserAdded { user } => { + let self_enrollment_enabled = !user.is_enrolled(); + let enrollment_flag_text = if self_enrollment_enabled { + "enabled" + } else { + "disabled" + }; + Some(format!( + "Added user {user} with email {} and self-enrollment {enrollment_flag_text}", + user.email + )) + } + DefguardEvent::UserRemoved { user } => Some(format!("Removed user {user}")), + DefguardEvent::UserModified { before, after } => { + let mut description = format!("Modified user {after}"); + + // check if status has changed + if before.is_active != after.is_active { + let status_change_text = if after.is_active { + "enabled" + } else { + "disabled" + }; + description = format!("{description}, status changed to {status_change_text}"); + } + Some(description) + } + DefguardEvent::UserGroupsModified { + user, + before, + after, + } => Some(format!( + "User groups modified! User:{user} Before: {before:?} After {after:?}" + )), + DefguardEvent::UserDeviceAdded { owner, device } => { + Some(format!("Added device {device} for user {owner}")) + } + DefguardEvent::UserDeviceRemoved { owner, device } => { + Some(format!("Removed device {device} owned by user {owner}")) + } + DefguardEvent::UserDeviceModified { + owner, + before: _, + after, + } => Some(format!("Modified device {after} owned by user {owner}")), + DefguardEvent::NetworkDeviceAdded { device, location } => Some(format!( + "Added network device {device} to location {location}" + )), + DefguardEvent::NetworkDeviceRemoved { device, location } => Some(format!( + "Removed network device {device} from location {location}" + )), + DefguardEvent::NetworkDeviceModified { + before: _, + after, + location, + } => Some(format!( + "Modified network device {after} in location {location}" + )), + DefguardEvent::ActivityLogStreamCreated { stream } => Some(format!( + "Created {} activity log stream {}", + stream.stream_type, stream.name + )), + DefguardEvent::ActivityLogStreamModified { before: _, after } => Some(format!( + "Modified {} activity log stream {}", + after.stream_type, after.name + )), + DefguardEvent::ActivityLogStreamRemoved { stream } => Some(format!( + "Removed {} activity log stream {}", + stream.stream_type, stream.name + )), + DefguardEvent::VpnLocationAdded { location } => { + Some(format!("Added VPN location {location}")) + } + DefguardEvent::VpnLocationRemoved { location } => { + Some(format!("Removed VPN location {location}")) + } + DefguardEvent::VpnLocationModified { before: _, after } => { + Some(format!("VPN location {after} was modified")) + } + DefguardEvent::ApiTokenAdded { owner, token } => { + Some(format!("Added API token {} for user {owner}", token.name)) + } + DefguardEvent::ApiTokenRemoved { owner, token } => Some(format!( + "Removed API token {} owned by user {owner}", + token.name + )), + DefguardEvent::ApiTokenRenamed { + owner, + token: _, + old_name, + new_name, + } => Some(format!( + "API token owned by user {owner} was renamed from {old_name} to {new_name}", + )), + DefguardEvent::OpenIdAppAdded { app } => { + Some(format!("Added OpenID application {}", app.name)) + } + DefguardEvent::OpenIdAppRemoved { app } => { + Some(format!("Removed OpenID application {}", app.name)) + } + DefguardEvent::OpenIdAppModified { before: _, after } => { + Some(format!("Modified OpenID application {}", after.name)) + } + DefguardEvent::OpenIdAppStateChanged { app, enabled } => { + let state = if *enabled { "Enabled" } else { "Disabled" }; + Some(format!("{} OpenID application {}", state, app.name)) + } + DefguardEvent::OpenIdProviderModified { provider } => { + Some(format!("Modified OpenID provider {}", provider.name)) + } + DefguardEvent::OpenIdProviderRemoved { provider } => { + Some(format!("Removed OpenID provider {}", provider.name)) + } + DefguardEvent::SettingsUpdated { + before: _, + after: _, + } => None, + DefguardEvent::SettingsUpdatedPartial { + before: _, + after: _, + } => None, + DefguardEvent::SettingsDefaultBrandingRestored => { + Some("Restored default branding settings".to_string()) + } + DefguardEvent::GroupsBulkAssigned { users, groups } => Some(format!( + "Assigned {} users to {} groups", + users.len(), + groups.len() + )), + DefguardEvent::GroupAdded { group } => Some(format!("Added group {}", group.name)), + DefguardEvent::GroupModified { before: _, after } => { + Some(format!("Modified group {}", after.name)) + } + DefguardEvent::GroupRemoved { group } => Some(format!("Removed group {}", group.name)), + DefguardEvent::GroupMemberAdded { group, user } => { + Some(format!("Added user {user} to group {}", group.name)) + } + DefguardEvent::GroupMemberRemoved { group, user } => { + Some(format!("Removed user {user} from group {}", group.name)) + } + DefguardEvent::GroupMembersModified { + group, + added, + removed, + } => Some(format!( + "Added: {}, Removed: {}, for group {}", + added + .iter() + .map(|user| user.username.clone()) + .collect::>() + .join(", "), + removed + .iter() + .map(|user| user.username.clone()) + .collect::>() + .join(", "), + group.name + )), + DefguardEvent::WebHookAdded { webhook } => { + Some(format!("Added webhook with URL {}", webhook.url)) + } + DefguardEvent::WebHookModified { before: _, after } => { + Some(format!("Modified webhook with URL {}", after.url)) + } + DefguardEvent::WebHookRemoved { webhook } => { + Some(format!("Removed webhook with ULR {}", webhook.url)) + } + DefguardEvent::WebHookStateChanged { webhook, enabled } => { + let state = if *enabled { "Enabled" } else { "Disabled" }; + Some(format!("{} webhook with URL {}", state, webhook.url)) + } + DefguardEvent::AuthenticationKeyAdded { key } => Some(format!( + "Added {} authentication key {}", + key.key_type, + key.name.clone().unwrap_or_default() + )), + DefguardEvent::AuthenticationKeyRemoved { key } => Some(format!( + "Removed {} authentication key {}", + key.key_type, + key.name.clone().unwrap_or_default() + )), + DefguardEvent::AuthenticationKeyRenamed { + key, + old_name, + new_name, + } => Some(format!( + "Renamed {} authentication key from {} to {}", + key.key_type, + old_name.clone().unwrap_or_default(), + new_name.clone().unwrap_or_default() + )), + DefguardEvent::ClientConfigurationTokenAdded { user } => { + Some(format!("Added client configuration token for user {user}",)) + } + DefguardEvent::UserSnatBindingAdded { user, binding } => Some(format!( + "Devices owned by user {user} bound to public IP {}", + binding.public_ip + )), + DefguardEvent::UserSnatBindingRemoved { user, binding } => Some(format!( + "Removed public IP {} binding for user {user}", + binding.public_ip + )), + DefguardEvent::UserSnatBindingModified { + user, + before, + after, + } => Some(format!( + "Public IP bound to devices owned by user {user} changed from {} to {}", + before.public_ip, after.public_ip + )), + } +} + +#[must_use] +pub fn get_vpn_event_description(event: &VpnEvent) -> Option { + match event { + VpnEvent::ConnectedToMfaLocation { + location, + device, + method, + } => Some(format!( + "Device {device} connected to MFA location {location} using {method}" + )), + VpnEvent::DisconnectedFromMfaLocation { location, device } => Some(format!( + "Device {device} disconnected from MFA location {location}" + )), + VpnEvent::MfaFailed { + location, + device, + method, + message, + } => Some(format!( + "Device {device} failed to connect to MFA location {location} using {method} with: {message}" + )), + VpnEvent::ConnectedToLocation { location, device } => { + Some(format!("Device {device} connected to location {location}")) + } + VpnEvent::DisconnectedFromLocation { location, device } => Some(format!( + "Device {device} disconnected from location {location}" + )), + } +} + +#[must_use] +pub fn get_enrollment_event_description(event: &EnrollmentEvent) -> Option { + match event { + EnrollmentEvent::EnrollmentStarted => Some("User started enrollment process".to_string()), + EnrollmentEvent::EnrollmentDeviceAdded { device } => { + Some(format!("Added device {} during enrollment", device.name)) + } + EnrollmentEvent::EnrollmentCompleted => { + Some("User completed enrollment process".to_string()) + } + EnrollmentEvent::PasswordResetRequested => None, + EnrollmentEvent::PasswordResetStarted => None, + EnrollmentEvent::PasswordResetCompleted => None, + EnrollmentEvent::TokenAdded { user } => { + Some(format!("Added enrollment token for user {user}")) + } + } +} diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index 47282bb760..3d04dce4eb 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -1,16 +1,28 @@ use bytes::Bytes; use defguard_core::db::{ + NoId, models::activity_log::{ + ActivityLogEvent, ActivityLogModule, EventType, metadata::{ - ActivityLogStreamMetadata, DeviceAddedMetadata, DeviceModifiedMetadata, - DeviceRemovedMetadata, EnrollmentDeviceAddedMetadata, MfaLoginMetadata, - MfaSecurityKeyAddedMetadata, MfaSecurityKeyRemovedMetadata, NetworkDeviceAddedMetadata, - NetworkDeviceModifiedMetadata, NetworkDeviceRemovedMetadata, UserAddedMetadata, - UserModifiedMetadata, UserRemovedMetadata, VpnClientMetadata, VpnClientMfaMetadata, + ActivityLogStreamMetadata, ActivityLogStreamModifiedMetadata, ApiTokenMetadata, + ApiTokenRenamedMetadata, AuthenticationKeyMetadata, AuthenticationKeyRenamedMetadata, + ClientConfigurationTokenMetadata, DeviceMetadata, DeviceModifiedMetadata, + EnrollmentDeviceAddedMetadata, EnrollmentTokenMetadata, GroupAssignedMetadata, + 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, + WebHookMetadata, WebHookModifiedMetadata, WebHookStateChangedMetadata, }, - ActivityLogEvent, ActivityLogModule, EventType, }, - NoId, +}; +use description::{ + get_defguard_event_description, get_enrollment_event_description, get_vpn_event_description, }; use error::EventLoggerError; use message::{ @@ -20,6 +32,7 @@ use sqlx::PgPool; use tokio::sync::mpsc::UnboundedReceiver; use tracing::{debug, error, info, trace}; +pub mod description; pub mod error; pub mod message; @@ -55,6 +68,7 @@ pub async fn run_event_logger( let EventContext { user_id, username, + location, timestamp, ip, device, @@ -62,255 +76,420 @@ pub async fn run_event_logger( // Convert each message to a related activity log event let activity_log_event = { - let (module, event, metadata) = match message.event { + let (module, event, description, metadata) = match message.event { LoggerEvent::Defguard(event) => { let module = ActivityLogModule::Defguard; + let description = get_defguard_event_description(&event); - let (event_type, metadata) = match event { + let (event_type, metadata) = match *event { DefguardEvent::UserLogin => (EventType::UserLogin, None), - DefguardEvent::UserLoginFailed => (EventType::UserLoginFailed, None), + DefguardEvent::UserLoginFailed { message } => ( + EventType::UserLoginFailed, + serde_json::to_value(LoginFailedMetadata { message }).ok(), + ), DefguardEvent::UserMfaLogin { mfa_method } => ( EventType::UserMfaLogin, serde_json::to_value(MfaLoginMetadata { mfa_method }).ok(), ), - DefguardEvent::UserMfaLoginFailed { mfa_method } => ( + DefguardEvent::UserMfaLoginFailed { + mfa_method, + message, + } => ( EventType::UserMfaLoginFailed, - serde_json::to_value(MfaLoginMetadata { mfa_method }).ok(), + serde_json::to_value(MfaLoginFailedMetadata { + mfa_method, + message, + }) + .ok(), ), DefguardEvent::UserLogout => (EventType::UserLogout, None), - DefguardEvent::UserDeviceAdded { - device_id: _, - device_name, - owner: _, - } => ( + DefguardEvent::UserDeviceAdded { owner, device } => ( EventType::DeviceAdded, - serde_json::to_value(DeviceAddedMetadata { - device_names: vec![device_name], + serde_json::to_value(DeviceMetadata { + owner: owner.into(), + device, }) .ok(), ), - DefguardEvent::UserDeviceRemoved { - device_id: _, - device_name, - owner: _, - } => ( + DefguardEvent::UserDeviceRemoved { owner, device } => ( EventType::DeviceRemoved, - serde_json::to_value(DeviceRemovedMetadata { - device_names: vec![device_name], + serde_json::to_value(DeviceMetadata { + owner: owner.into(), + device, }) .ok(), ), DefguardEvent::UserDeviceModified { - device_id: _, - device_name, - owner: _, + owner, + before, + after, } => ( EventType::DeviceModified, serde_json::to_value(DeviceModifiedMetadata { - device_names: vec![device_name], + owner: owner.into(), + before, + after, + }) + .ok(), + ), + DefguardEvent::UserGroupsModified { + user, + before, + after, + } => ( + EventType::UserGroupsModified, + serde_json::to_value(UserGroupsModifiedMetadata { + user: user.into(), + before, + after, }) .ok(), ), DefguardEvent::RecoveryCodeUsed => (EventType::RecoveryCodeUsed, None), - DefguardEvent::PasswordChanged => todo!(), + DefguardEvent::PasswordChanged => (EventType::PasswordChanged, None), + DefguardEvent::PasswordChangedByAdmin { user } => ( + EventType::PasswordChangedByAdmin, + serde_json::to_value(PasswordChangedByAdminMetadata { + user: user.into(), + }) + .ok(), + ), DefguardEvent::MfaDisabled => (EventType::MfaDisabled, None), + DefguardEvent::UserMfaDisabled { user } => ( + EventType::UserMfaDisabled, + serde_json::to_value(UserMfaDisabledMetadata { user: user.into() }) + .ok(), + ), DefguardEvent::MfaTotpEnabled => (EventType::MfaTotpEnabled, None), DefguardEvent::MfaTotpDisabled => (EventType::MfaTotpDisabled, None), DefguardEvent::MfaEmailEnabled => (EventType::MfaEmailEnabled, None), DefguardEvent::MfaEmailDisabled => (EventType::MfaEmailDisabled, None), - DefguardEvent::MfaSecurityKeyAdded { key_id, key_name } => ( + DefguardEvent::MfaSecurityKeyAdded { key } => ( EventType::MfaSecurityKeyAdded, - serde_json::to_value(MfaSecurityKeyAddedMetadata { - key_id, - key_name, - }) - .ok(), + serde_json::to_value(MfaSecurityKeyMetadata { key: key.into() }) + .ok(), ), - DefguardEvent::MfaSecurityKeyRemoved { key_id, key_name } => ( + DefguardEvent::MfaSecurityKeyRemoved { key } => ( EventType::MfaSecurityKeyRemoved, - serde_json::to_value(MfaSecurityKeyRemovedMetadata { - key_id, - key_name, - }) - .ok(), - ), - DefguardEvent::AuthenticationKeyAdded { - key_id: _, - key_name: _, - key_type: _, - } => todo!(), - DefguardEvent::AuthenticationKeyRemoved { - key_id: _, - key_name: _, - key_type: _, - } => todo!(), + serde_json::to_value(MfaSecurityKeyMetadata { key: key.into() }) + .ok(), + ), + DefguardEvent::AuthenticationKeyAdded { key } => ( + EventType::AuthenticationKeyAdded, + serde_json::to_value(AuthenticationKeyMetadata { key: key.into() }) + .ok(), + ), + DefguardEvent::AuthenticationKeyRemoved { key } => ( + EventType::AuthenticationKeyRemoved, + serde_json::to_value(AuthenticationKeyMetadata { key: key.into() }) + .ok(), + ), DefguardEvent::AuthenticationKeyRenamed { - key_id: _, - key_name: _, - key_type: _, - } => todo!(), - DefguardEvent::ApiTokenAdded { - token_id: _, - token_name: _, - } => todo!(), - DefguardEvent::ApiTokenRemoved { - token_id: _, - token_name: _, - } => todo!(), + key, + old_name, + new_name, + } => ( + EventType::AuthenticationKeyRenamed, + serde_json::to_value(AuthenticationKeyRenamedMetadata { + key: key.into(), + old_name, + new_name, + }) + .ok(), + ), + DefguardEvent::ApiTokenAdded { owner, token } => ( + EventType::ApiTokenAdded, + serde_json::to_value(ApiTokenMetadata { + owner: owner.into(), + token: token.into(), + }) + .ok(), + ), + DefguardEvent::ApiTokenRemoved { owner, token } => ( + EventType::ApiTokenRemoved, + serde_json::to_value(ApiTokenMetadata { + owner: owner.into(), + token: token.into(), + }) + .ok(), + ), DefguardEvent::ApiTokenRenamed { - token_id: _, - token_name: _, - } => todo!(), - DefguardEvent::UserAdded { username } => ( + owner, + token, + old_name, + new_name, + } => ( + EventType::ApiTokenRenamed, + serde_json::to_value(ApiTokenRenamedMetadata { + owner: owner.into(), + token: token.into(), + old_name, + new_name, + }) + .ok(), + ), + DefguardEvent::UserAdded { user } => ( EventType::UserAdded, - serde_json::to_value(UserAddedMetadata { username }).ok(), + serde_json::to_value(UserMetadata { user: user.into() }).ok(), ), - DefguardEvent::UserRemoved { username } => ( + DefguardEvent::UserRemoved { user } => ( EventType::UserRemoved, - serde_json::to_value(UserRemovedMetadata { username }).ok(), + serde_json::to_value(UserMetadata { user: user.into() }).ok(), ), - DefguardEvent::UserModified { username } => ( + DefguardEvent::UserModified { before, after } => ( EventType::UserModified, - serde_json::to_value(UserModifiedMetadata { username }).ok(), - ), - DefguardEvent::UserDisabled { username: _ } => todo!(), - DefguardEvent::NetworkDeviceAdded { - device_id, - device_name, - location_id, - location, - } => ( - EventType::NetworkDeviceAdded, - serde_json::to_value(NetworkDeviceAddedMetadata { - device_id, - device_name, - location_id, - location, + serde_json::to_value(UserModifiedMetadata { + before: before.into(), + after: after.into(), }) .ok(), ), - DefguardEvent::NetworkDeviceRemoved { - device_id, - device_name, - location_id, - location, - } => ( + DefguardEvent::NetworkDeviceAdded { device, location } => ( + EventType::NetworkDeviceAdded, + serde_json::to_value(NetworkDeviceMetadata { device, location }) + .ok(), + ), + DefguardEvent::NetworkDeviceRemoved { device, location } => ( EventType::NetworkDeviceRemoved, - serde_json::to_value(NetworkDeviceRemovedMetadata { - device_id, - device_name, - location_id, - location, - }) - .ok(), + serde_json::to_value(NetworkDeviceMetadata { device, location }) + .ok(), ), DefguardEvent::NetworkDeviceModified { - device_id, - device_name, - location_id, location, + before, + after, } => ( EventType::NetworkDeviceModified, serde_json::to_value(NetworkDeviceModifiedMetadata { - device_id, - device_name, - location_id, + before, + after, location, }) .ok(), ), - DefguardEvent::VpnLocationAdded { - location_id: _, - location_name: _, - } => todo!(), - DefguardEvent::VpnLocationRemoved { - location_id: _, - location_name: _, - } => todo!(), - DefguardEvent::VpnLocationModified { - location_id: _, - location_name: _, - } => todo!(), - DefguardEvent::OpenIdAppAdded { - app_id: _, - app_name: _, - } => todo!(), - DefguardEvent::OpenIdAppRemoved { - app_id: _, - app_name: _, - } => todo!(), - DefguardEvent::OpenIdAppModified { - app_id: _, - app_name: _, - } => todo!(), - DefguardEvent::OpenIdAppDisabled { - app_id: _, - app_name: _, - } => todo!(), - DefguardEvent::OpenIdProviderAdded { - provider_id: _, - provider_name: _, - } => todo!(), - DefguardEvent::OpenIdProviderRemoved { - provider_id: _, - provider_name: _, - } => todo!(), - DefguardEvent::SettingsUpdated => todo!(), - DefguardEvent::SettingsUpdatedPartial => todo!(), - DefguardEvent::SettingsDefaultBrandingRestored => todo!(), - DefguardEvent::ActivityLogStreamCreated { - stream_id, - stream_name, - } => ( + DefguardEvent::VpnLocationAdded { location } => ( + EventType::VpnLocationAdded, + serde_json::to_value(VpnLocationMetadata { location }).ok(), + ), + DefguardEvent::VpnLocationRemoved { location } => ( + EventType::VpnLocationRemoved, + serde_json::to_value(VpnLocationMetadata { location }).ok(), + ), + DefguardEvent::VpnLocationModified { before, after } => ( + EventType::VpnLocationModified, + serde_json::to_value(VpnLocationModifiedMetadata { before, after }) + .ok(), + ), + DefguardEvent::OpenIdAppAdded { app } => ( + EventType::OpenIdAppAdded, + serde_json::to_value(OpenIdAppMetadata { app: app.into() }).ok(), + ), + DefguardEvent::OpenIdAppRemoved { app } => ( + EventType::OpenIdAppRemoved, + serde_json::to_value(OpenIdAppMetadata { app: app.into() }).ok(), + ), + DefguardEvent::OpenIdAppModified { before, after } => ( + EventType::OpenIdAppModified, + serde_json::to_value(OpenIdAppModifiedMetadata { + before: before.into(), + after: after.into(), + }) + .ok(), + ), + DefguardEvent::OpenIdAppStateChanged { app, enabled } => ( + EventType::OpenIdAppStateChanged, + serde_json::to_value(OpenIdAppStateChangedMetadata { + app: app.into(), + enabled, + }) + .ok(), + ), + DefguardEvent::OpenIdProviderModified { provider } => ( + EventType::OpenIdProviderModified, + serde_json::to_value(OpenIdProviderMetadata { + provider: provider.into(), + }) + .ok(), + ), + DefguardEvent::OpenIdProviderRemoved { provider } => ( + EventType::OpenIdProviderRemoved, + serde_json::to_value(OpenIdProviderMetadata { + provider: provider.into(), + }) + .ok(), + ), + DefguardEvent::SettingsUpdatedPartial { before, after } => ( + EventType::SettingsUpdatedPartial, + serde_json::to_value(SettingsUpdateMetadata { + before: before.into(), + after: after.into(), + }) + .ok(), + ), + DefguardEvent::SettingsUpdated { before, after } => ( + EventType::SettingsUpdated, + serde_json::to_value(SettingsUpdateMetadata { + before: before.into(), + after: after.into(), + }) + .ok(), + ), + DefguardEvent::SettingsDefaultBrandingRestored => { + (EventType::SettingsDefaultBrandingRestored, None) + } + DefguardEvent::ActivityLogStreamCreated { stream } => ( EventType::ActivityLogStreamCreated, serde_json::to_value(ActivityLogStreamMetadata { - id: stream_id, - name: stream_name, + stream: stream.into(), }) .ok(), ), - DefguardEvent::ActivityLogStreamRemoved { - stream_id, - stream_name, - } => ( + DefguardEvent::ActivityLogStreamRemoved { stream } => ( EventType::ActivityLogStreamRemoved, serde_json::to_value(ActivityLogStreamMetadata { - id: stream_id, - name: stream_name, + stream: stream.into(), }) .ok(), ), - DefguardEvent::ActivityLogStreamModified { - stream_id, - stream_name, - } => ( + DefguardEvent::ActivityLogStreamModified { before, after } => ( EventType::ActivityLogStreamModified, - serde_json::to_value(ActivityLogStreamMetadata { - id: stream_id, - name: stream_name, + serde_json::to_value(ActivityLogStreamModifiedMetadata { + before: before.into(), + after: after.into(), + }) + .ok(), + ), + DefguardEvent::GroupsBulkAssigned { users, groups } => ( + EventType::GroupsBulkAssigned, + serde_json::to_value(GroupsBulkAssignedMetadata { + users: users.into_iter().map(Into::into).collect(), + groups, + }) + .ok(), + ), + DefguardEvent::GroupAdded { group } => ( + EventType::GroupAdded, + serde_json::to_value(GroupMetadata { group }).ok(), + ), + DefguardEvent::GroupModified { before, after } => ( + EventType::GroupModified, + serde_json::to_value(GroupModifiedMetadata { before, after }).ok(), + ), + DefguardEvent::GroupRemoved { group } => ( + EventType::GroupRemoved, + serde_json::to_value(GroupMetadata { group }).ok(), + ), + DefguardEvent::GroupMemberAdded { group, user } => ( + EventType::GroupMemberAdded, + serde_json::to_value(GroupAssignedMetadata { + group, + user: user.into(), + }) + .ok(), + ), + DefguardEvent::GroupMemberRemoved { group, user } => ( + EventType::GroupMemberRemoved, + serde_json::to_value(GroupAssignedMetadata { + group, + user: user.into(), + }) + .ok(), + ), + DefguardEvent::GroupMembersModified { + group, + added, + removed, + } => ( + EventType::GroupMembersModified, + serde_json::to_value(GroupMembersModifiedMetadata { + group, + added: added.into_iter().map(Into::into).collect(), + removed: removed.into_iter().map(Into::into).collect(), + }) + .ok(), + ), + DefguardEvent::WebHookAdded { webhook } => ( + EventType::WebHookAdded, + serde_json::to_value(WebHookMetadata { webhook }).ok(), + ), + DefguardEvent::WebHookModified { before, after } => ( + EventType::WebHookModified, + serde_json::to_value(WebHookModifiedMetadata { before, after }) + .ok(), + ), + DefguardEvent::WebHookRemoved { webhook } => ( + EventType::WebHookRemoved, + serde_json::to_value(WebHookMetadata { webhook }).ok(), + ), + DefguardEvent::WebHookStateChanged { webhook, enabled } => ( + EventType::WebHookStateChanged, + serde_json::to_value(WebHookStateChangedMetadata { + webhook, + enabled, + }) + .ok(), + ), + DefguardEvent::PasswordReset { user } => ( + EventType::PasswordReset, + serde_json::to_value(PasswordResetMetadata { user: user.into() }) + .ok(), + ), + DefguardEvent::ClientConfigurationTokenAdded { user } => ( + EventType::ClientConfigurationTokenAdded, + serde_json::to_value(ClientConfigurationTokenMetadata { + user: user.into(), + }) + .ok(), + ), + DefguardEvent::UserSnatBindingAdded { user, binding } => ( + EventType::UserSnatBindingAdded, + serde_json::to_value(UserSnatBindingMetadata { + user: user.into(), + binding, + }) + .ok(), + ), + DefguardEvent::UserSnatBindingRemoved { user, binding } => ( + EventType::UserSnatBindingRemoved, + serde_json::to_value(UserSnatBindingMetadata { + user: user.into(), + binding, + }) + .ok(), + ), + DefguardEvent::UserSnatBindingModified { + user, + before, + after, + } => ( + EventType::UserSnatBindingModified, + serde_json::to_value(UserSnatBindingModifiedMetadata { + user: user.into(), + before, + after, }) .ok(), ), }; - (module, event_type, metadata) - } - LoggerEvent::Client(_event) => { - let _module = ActivityLogModule::Client; - unimplemented!() + (module, event_type, description, metadata) } LoggerEvent::Vpn(event) => { let module = ActivityLogModule::Vpn; - let (event_type, metadata) = match event { + let description = get_vpn_event_description(&event); + + let (event_type, metadata) = match *event { VpnEvent::MfaFailed { location, device, method, + message, } => ( EventType::VpnClientMfaFailed, - serde_json::to_value(VpnClientMfaMetadata { + serde_json::to_value(VpnClientMfaFailedMetadata { location, device, method, + message, }) .ok(), ), @@ -340,11 +519,13 @@ pub async fn run_event_logger( serde_json::to_value(VpnClientMetadata { location, device }).ok(), ), }; - (module, event_type, metadata) + (module, event_type, description, metadata) } LoggerEvent::Enrollment(event) => { let module = ActivityLogModule::Enrollment; - let (event_type, metadata) = match event { + let description = get_enrollment_event_description(&event); + + let (event_type, metadata) = match *event { EnrollmentEvent::EnrollmentStarted => { (EventType::EnrollmentStarted, None) } @@ -364,8 +545,13 @@ pub async fn run_event_logger( EnrollmentEvent::PasswordResetCompleted => { (EventType::PasswordResetCompleted, None) } + EnrollmentEvent::TokenAdded { user } => ( + EventType::EnrollmentTokenAdded, + serde_json::to_value(EnrollmentTokenMetadata { user: user.into() }) + .ok(), + ), }; - (module, event_type, metadata) + (module, event_type, description, metadata) } }; @@ -374,10 +560,12 @@ pub async fn run_event_logger( timestamp, user_id, username, + location, ip: ip.into(), event, module, device, + description, metadata, } }; @@ -400,7 +588,9 @@ pub async fn run_event_logger( if !serialized_activity_log_events.is_empty() { let in_bytes = bytes::Bytes::from(serialized_activity_log_events); if let Err(send_err) = activity_log_messages_tx.send(in_bytes) { - trace!("Sending serialized activity log events message failed. Most likely because there is no listeners. Reason: {send_err}"); + trace!( + "Sending serialized activity log events message failed. Most likely because there is no listeners. Reason: {send_err}" + ); } } diff --git a/crates/defguard_event_logger/src/message.rs b/crates/defguard_event_logger/src/message.rs index 891a14fbbd..26b4145689 100644 --- a/crates/defguard_event_logger/src/message.rs +++ b/crates/defguard_event_logger/src/message.rs @@ -3,9 +3,17 @@ use std::net::IpAddr; use chrono::NaiveDateTime; use defguard_core::{ db::{ - models::authentication_key::AuthenticationKeyType, Device, Id, MFAMethod, WireguardNetwork, + Device, Group, Id, MFAMethod, Settings, User, WebAuthn, WebHook, WireguardNetwork, + models::{authentication_key::AuthenticationKey, oauth2client::OAuth2Client}, + }, + enterprise::db::models::{ + activity_log_stream::ActivityLogStream, api_tokens::ApiToken, + openid_provider::OpenIdProvider, snat::UserSnatBinding, + }, + events::{ + ApiRequestContext, BidiRequestContext, ClientMFAMethod, GrpcRequestContext, + InternalEventContext, }, - events::{ApiRequestContext, BidiRequestContext, GrpcRequestContext, InternalEventContext}, }; /// Messages that can be sent to the event logger @@ -15,237 +23,302 @@ pub struct EventLoggerMessage { } impl EventLoggerMessage { + #[must_use] pub fn new(context: EventContext, event: LoggerEvent) -> Self { Self { context, event } } } /// Possible activity log event types split by module -// TODO: remove lint override below once all events are updated to pass whole objects -#[allow(clippy::large_enum_variant)] pub enum LoggerEvent { - Defguard(DefguardEvent), - Client(ClientEvent), - Vpn(VpnEvent), - Enrollment(EnrollmentEvent), + Defguard(Box), + Vpn(Box), + Enrollment(Box), } -/// Shared context that's included in all events +/// Shared context that's included in all activity log events pub struct EventContext { pub timestamp: NaiveDateTime, pub user_id: Id, pub username: String, + pub location: Option, pub ip: IpAddr, pub device: String, } -impl From for EventContext { - fn from(val: ApiRequestContext) -> Self { +impl EventContext { + #[must_use] + pub fn from_api_context( + val: ApiRequestContext, + location: Option>, + ) -> Self { + let location = location.map(|location| location.name); + EventContext { timestamp: val.timestamp, user_id: val.user_id, username: val.username, + location, ip: val.ip, device: val.device, } } -} -impl From for EventContext { - fn from(val: GrpcRequestContext) -> Self { + #[must_use] + pub fn from_bidi_context( + val: BidiRequestContext, + location: Option>, + ) -> Self { + let location = location.map(|location| location.name); + EventContext { timestamp: val.timestamp, user_id: val.user_id, username: val.username, + location, ip: val.ip, - device: format!("{} (ID {})", val.device_name, val.device_id), + device: val.device_name, } } -} -impl From for EventContext { - fn from(val: BidiRequestContext) -> Self { + #[must_use] + pub fn from_internal_context( + val: InternalEventContext, + location: Option>, + ) -> Self { + let location = location.map(|location| location.name); + EventContext { timestamp: val.timestamp, user_id: val.user_id, username: val.username, + location, ip: val.ip, - device: val.user_agent, + device: format!("{} (ID {})", val.device.name, val.device.id), } } } -impl From for EventContext { - fn from(val: InternalEventContext) -> Self { - EventContext { +impl From for EventContext { + fn from(val: GrpcRequestContext) -> Self { + Self { timestamp: val.timestamp, user_id: val.user_id, username: val.username, + location: Some(val.location.name), ip: val.ip, - device: format!("{} (ID {})", val.device.name, val.device.id), + device: format!("{} (ID {})", val.device_name, val.device_id), } } } /// Represents activity log events related to actions performed in Web UI pub enum DefguardEvent { - // authentication UserLogin, - UserLoginFailed, + UserLoginFailed { + message: String, + }, + UserLogout, UserMfaLogin { mfa_method: MFAMethod, }, UserMfaLoginFailed { mfa_method: MFAMethod, + message: String, }, - UserLogout, RecoveryCodeUsed, + PasswordChangedByAdmin { + user: User, + }, PasswordChanged, - // user MFA management + PasswordReset { + user: User, + }, MfaDisabled, - MfaTotpEnabled, + UserMfaDisabled { + user: User, + }, MfaTotpDisabled, - MfaEmailEnabled, + MfaTotpEnabled, MfaEmailDisabled, + MfaEmailEnabled, MfaSecurityKeyAdded { - key_id: Id, - key_name: String, + key: WebAuthn, }, MfaSecurityKeyRemoved { - key_id: Id, - key_name: String, - }, - // authentication key management - AuthenticationKeyAdded { - key_id: Id, - key_name: String, - key_type: AuthenticationKeyType, - }, - AuthenticationKeyRemoved { - key_id: Id, - key_name: String, - key_type: AuthenticationKeyType, - }, - AuthenticationKeyRenamed { - key_id: Id, - key_name: String, - key_type: AuthenticationKeyType, - }, - // API token management - ApiTokenAdded { - token_id: Id, - token_name: String, - }, - ApiTokenRemoved { - token_id: Id, - token_name: String, + key: WebAuthn, }, - ApiTokenRenamed { - token_id: Id, - token_name: String, - }, - // user management UserAdded { - username: String, + user: User, }, UserRemoved { - username: String, + user: User, }, UserModified { - username: String, + before: User, + after: User, }, - UserDisabled { - username: String, + UserGroupsModified { + user: User, + before: Vec, + after: Vec, }, - // device management UserDeviceAdded { - device_id: Id, - device_name: String, - owner: String, + owner: User, + device: Device, }, UserDeviceRemoved { - device_id: Id, - device_name: String, - owner: String, + owner: User, + device: Device, }, UserDeviceModified { - device_id: Id, - device_name: String, - owner: String, + owner: User, + before: Device, + after: Device, }, NetworkDeviceAdded { - device_id: Id, - device_name: String, - location_id: Id, - location: String, + device: Device, + location: WireguardNetwork, }, NetworkDeviceRemoved { - device_id: Id, - device_name: String, - location_id: Id, - location: String, + device: Device, + location: WireguardNetwork, }, NetworkDeviceModified { - device_id: Id, - device_name: String, - location_id: Id, - location: String, + before: Device, + after: Device, + location: WireguardNetwork, + }, + ActivityLogStreamCreated { + stream: ActivityLogStream, + }, + ActivityLogStreamModified { + before: ActivityLogStream, + after: ActivityLogStream, + }, + ActivityLogStreamRemoved { + stream: ActivityLogStream, }, - // VPN location management VpnLocationAdded { - location_id: Id, - location_name: String, + location: WireguardNetwork, }, VpnLocationRemoved { - location_id: Id, - location_name: String, + location: WireguardNetwork, }, VpnLocationModified { - location_id: Id, - location_name: String, + before: WireguardNetwork, + after: WireguardNetwork, + }, + ApiTokenAdded { + owner: User, + token: ApiToken, + }, + ApiTokenRemoved { + owner: User, + token: ApiToken, + }, + ApiTokenRenamed { + owner: User, + token: ApiToken, + old_name: String, + new_name: String, }, - // OpenID app management OpenIdAppAdded { - app_id: Id, - app_name: String, + app: OAuth2Client, }, OpenIdAppRemoved { - app_id: Id, - app_name: String, + app: OAuth2Client, }, OpenIdAppModified { - app_id: Id, - app_name: String, + before: OAuth2Client, + after: OAuth2Client, }, - OpenIdAppDisabled { - app_id: Id, - app_name: String, + OpenIdAppStateChanged { + app: OAuth2Client, + enabled: bool, }, - // OpenID provider management - OpenIdProviderAdded { - provider_id: Id, - provider_name: String, + OpenIdProviderModified { + provider: OpenIdProvider, }, OpenIdProviderRemoved { - provider_id: Id, - provider_name: String, + provider: OpenIdProvider, + }, + SettingsUpdated { + before: Settings, + after: Settings, + }, + SettingsUpdatedPartial { + before: Settings, + after: Settings, }, - // settings management - SettingsUpdated, - SettingsUpdatedPartial, SettingsDefaultBrandingRestored, - // activity log stream management - ActivityLogStreamCreated { - stream_id: Id, - stream_name: String, + GroupsBulkAssigned { + users: Vec>, + groups: Vec>, }, - ActivityLogStreamModified { - stream_id: Id, - stream_name: String, + GroupAdded { + group: Group, }, - ActivityLogStreamRemoved { - stream_id: Id, - stream_name: String, + GroupModified { + before: Group, + after: Group, + }, + GroupRemoved { + group: Group, + }, + GroupMemberAdded { + group: Group, + user: User, + }, + GroupMemberRemoved { + group: Group, + user: User, + }, + GroupMembersModified { + group: Group, + added: Vec>, + removed: Vec>, + }, + WebHookAdded { + webhook: WebHook, + }, + WebHookModified { + before: WebHook, + after: WebHook, + }, + WebHookRemoved { + webhook: WebHook, + }, + WebHookStateChanged { + webhook: WebHook, + enabled: bool, + }, + AuthenticationKeyAdded { + key: AuthenticationKey, + }, + AuthenticationKeyRemoved { + key: AuthenticationKey, + }, + AuthenticationKeyRenamed { + key: AuthenticationKey, + old_name: Option, + new_name: Option, + }, + ClientConfigurationTokenAdded { + user: User, + }, + UserSnatBindingAdded { + user: User, + binding: UserSnatBinding, + }, + UserSnatBindingRemoved { + user: User, + binding: UserSnatBinding, + }, + UserSnatBindingModified { + user: User, + before: UserSnatBinding, + after: UserSnatBinding, }, } @@ -260,7 +333,7 @@ pub enum VpnEvent { ConnectedToMfaLocation { location: WireguardNetwork, device: Device, - method: MFAMethod, + method: ClientMFAMethod, }, DisconnectedFromMfaLocation { location: WireguardNetwork, @@ -269,7 +342,8 @@ pub enum VpnEvent { MfaFailed { location: WireguardNetwork, device: Device, - method: MFAMethod, + method: ClientMFAMethod, + message: String, }, ConnectedToLocation { location: WireguardNetwork, @@ -289,4 +363,5 @@ pub enum EnrollmentEvent { PasswordResetRequested, PasswordResetStarted, PasswordResetCompleted, + TokenAdded { user: User }, } diff --git a/crates/defguard_event_router/src/events.rs b/crates/defguard_event_router/src/events.rs index 02c0fb10b8..d911b82586 100644 --- a/crates/defguard_event_router/src/events.rs +++ b/crates/defguard_event_router/src/events.rs @@ -4,12 +4,9 @@ use defguard_core::events::{ApiEvent, BidiStreamEvent, GrpcEvent, InternalEvent} /// /// System components can send events to the event router through their own event channels. /// The enum itself is organized based on event source to make splitting logic into smaller chunks easier. -// TODO: remove lint override below once all events are updated to pass whole objects -#[allow(clippy::large_enum_variant)] -#[derive(Debug)] pub enum Event { Api(ApiEvent), - Grpc(GrpcEvent), + Grpc(Box), Bidi(BidiStreamEvent), - Internal(InternalEvent), + Internal(Box), } diff --git a/crates/defguard_event_router/src/handlers/api.rs b/crates/defguard_event_router/src/handlers/api.rs index 64973706aa..45e809a48f 100644 --- a/crates/defguard_event_router/src/handlers/api.rs +++ b/crates/defguard_event_router/src/handlers/api.rs @@ -1,141 +1,398 @@ use defguard_core::events::{ApiEvent, ApiEventType}; -use defguard_event_logger::message::{DefguardEvent, LoggerEvent}; +use defguard_event_logger::message::{DefguardEvent, EnrollmentEvent, EventContext, LoggerEvent}; use tracing::debug; -use crate::{error::EventRouterError, EventRouter}; +use crate::{EventRouter, error::EventRouterError}; impl EventRouter { pub(crate) fn handle_api_event(&self, event: ApiEvent) -> Result<(), EventRouterError> { debug!("Processing API event: {event:?}"); - let logger_event = match event.event { - ApiEventType::UserLogin => LoggerEvent::Defguard(DefguardEvent::UserLogin), - ApiEventType::UserLoginFailed => LoggerEvent::Defguard(DefguardEvent::UserLoginFailed), - ApiEventType::UserMfaLogin { mfa_method } => { - LoggerEvent::Defguard(DefguardEvent::UserMfaLogin { mfa_method }) - } - ApiEventType::UserMfaLoginFailed { mfa_method } => { - LoggerEvent::Defguard(DefguardEvent::UserMfaLoginFailed { mfa_method }) - } - ApiEventType::RecoveryCodeUsed => { - LoggerEvent::Defguard(DefguardEvent::RecoveryCodeUsed) - } - ApiEventType::UserLogout => LoggerEvent::Defguard(DefguardEvent::UserLogout), - ApiEventType::UserAdded { username } => { - LoggerEvent::Defguard(DefguardEvent::UserAdded { username }) - } - ApiEventType::UserRemoved { username } => { - LoggerEvent::Defguard(DefguardEvent::UserRemoved { username }) - } - ApiEventType::UserModified { username } => { - LoggerEvent::Defguard(DefguardEvent::UserModified { username }) - } - ApiEventType::MfaDisabled => LoggerEvent::Defguard(DefguardEvent::MfaDisabled), - ApiEventType::MfaTotpDisabled => LoggerEvent::Defguard(DefguardEvent::MfaTotpDisabled), - ApiEventType::MfaTotpEnabled => LoggerEvent::Defguard(DefguardEvent::MfaTotpEnabled), - ApiEventType::MfaEmailDisabled => { - LoggerEvent::Defguard(DefguardEvent::MfaEmailDisabled) - } - ApiEventType::MfaEmailEnabled => LoggerEvent::Defguard(DefguardEvent::MfaEmailEnabled), - ApiEventType::MfaSecurityKeyAdded { key_id, key_name } => { - LoggerEvent::Defguard(DefguardEvent::MfaSecurityKeyAdded { key_id, key_name }) - } - ApiEventType::MfaSecurityKeyRemoved { key_id, key_name } => { - LoggerEvent::Defguard(DefguardEvent::MfaSecurityKeyRemoved { key_id, key_name }) - } - ApiEventType::UserDeviceAdded { - owner, - device_id, - device_name, - } => LoggerEvent::Defguard(DefguardEvent::UserDeviceAdded { - device_name, - device_id, - owner, - }), - ApiEventType::UserDeviceRemoved { - owner, - device_id, - device_name, - } => LoggerEvent::Defguard(DefguardEvent::UserDeviceRemoved { - device_name, - device_id, - owner, - }), + let (logger_event, location) = match *event.event { + ApiEventType::UserLogin => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserLogin)), + None, + ), + ApiEventType::UserLoginFailed { message } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserLoginFailed { message })), + None, + ), + ApiEventType::UserMfaLogin { mfa_method } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserMfaLogin { mfa_method })), + None, + ), + ApiEventType::UserMfaLoginFailed { + mfa_method, + message, + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserMfaLoginFailed { + mfa_method, + message, + })), + None, + ), + ApiEventType::RecoveryCodeUsed => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::RecoveryCodeUsed)), + None, + ), + ApiEventType::UserLogout => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserLogout)), + None, + ), + ApiEventType::UserAdded { user } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserAdded { user })), + None, + ), + ApiEventType::UserRemoved { user } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserRemoved { user })), + None, + ), + ApiEventType::UserModified { before, after } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserModified { before, after })), + None, + ), + ApiEventType::UserGroupsModified { + user, + before, + after, + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserGroupsModified { + user, + before, + after, + })), + None, + ), + ApiEventType::MfaDisabled => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::MfaDisabled)), + None, + ), + ApiEventType::UserMfaDisabled { user } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserMfaDisabled { user })), + None, + ), + ApiEventType::MfaTotpDisabled => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::MfaTotpDisabled)), + None, + ), + ApiEventType::MfaTotpEnabled => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::MfaTotpEnabled)), + None, + ), + ApiEventType::MfaEmailDisabled => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::MfaEmailDisabled)), + None, + ), + ApiEventType::MfaEmailEnabled => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::MfaEmailEnabled)), + None, + ), + ApiEventType::MfaSecurityKeyAdded { key } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::MfaSecurityKeyAdded { key })), + None, + ), + ApiEventType::MfaSecurityKeyRemoved { key } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::MfaSecurityKeyRemoved { key })), + None, + ), + ApiEventType::UserDeviceAdded { owner, device } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserDeviceAdded { device, owner })), + None, + ), + ApiEventType::UserDeviceRemoved { owner, device } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserDeviceRemoved { device, owner })), + None, + ), ApiEventType::UserDeviceModified { owner, - device_id, - device_name, - } => LoggerEvent::Defguard(DefguardEvent::UserDeviceModified { - device_name, - device_id, - owner, - }), - ApiEventType::NetworkDeviceAdded { - device_id, - device_name, - location_id, - location, - } => LoggerEvent::Defguard(DefguardEvent::NetworkDeviceAdded { - device_id, - device_name, - location_id, - location, - }), + before, + after, + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserDeviceModified { + owner, + before, + after, + })), + None, + ), + ApiEventType::NetworkDeviceAdded { device, location } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::NetworkDeviceAdded { + device, + location: location.clone(), + })), + Some(location), + ), ApiEventType::NetworkDeviceModified { - device_id, - device_name, - location_id, - location, - } => LoggerEvent::Defguard(DefguardEvent::NetworkDeviceModified { - device_id, - device_name, - location_id, - location, - }), - ApiEventType::NetworkDeviceRemoved { - device_id, - device_name, - location_id, + before, + after, location, - } => LoggerEvent::Defguard(DefguardEvent::NetworkDeviceRemoved { - device_id, - device_name, - location_id, - location, - }), - ApiEventType::ActivityLogStreamCreated { - stream_id, - stream_name, - } => { + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::NetworkDeviceModified { + before, + after, + location: location.clone(), + })), + Some(location), + ), + ApiEventType::NetworkDeviceRemoved { device, location } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::NetworkDeviceRemoved { + device, + location: location.clone(), + })), + Some(location), + ), + ApiEventType::ActivityLogStreamCreated { stream } => { // Notify stream manager about configuration changes self.activity_log_stream_reload_notify.notify_waiters(); - LoggerEvent::Defguard(DefguardEvent::ActivityLogStreamCreated { - stream_id, - stream_name, - }) + ( + LoggerEvent::Defguard(Box::new(DefguardEvent::ActivityLogStreamCreated { + stream, + })), + None, + ) } - ApiEventType::ActivityLogStreamModified { - stream_id, - stream_name, - } => { + ApiEventType::ActivityLogStreamModified { before, after } => { // Notify stream manager about configuration changes self.activity_log_stream_reload_notify.notify_waiters(); - LoggerEvent::Defguard(DefguardEvent::ActivityLogStreamModified { - stream_id, - stream_name, - }) + ( + LoggerEvent::Defguard(Box::new(DefguardEvent::ActivityLogStreamModified { + before, + after, + })), + None, + ) } - ApiEventType::ActivityLogStreamRemoved { - stream_id, - stream_name, - } => { + ApiEventType::ActivityLogStreamRemoved { stream } => { // Notify stream manager about configuration changes self.activity_log_stream_reload_notify.notify_waiters(); - LoggerEvent::Defguard(DefguardEvent::ActivityLogStreamRemoved { - stream_id, - stream_name, - }) + ( + LoggerEvent::Defguard(Box::new(DefguardEvent::ActivityLogStreamRemoved { + stream, + })), + None, + ) } + ApiEventType::VpnLocationAdded { location } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::VpnLocationAdded { + location: location.clone(), + })), + Some(location), + ), + ApiEventType::VpnLocationRemoved { location } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::VpnLocationRemoved { + location: location.clone(), + })), + Some(location), + ), + ApiEventType::VpnLocationModified { before, after } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::VpnLocationModified { + before, + after: after.clone(), + })), + Some(after), + ), + ApiEventType::ApiTokenAdded { owner, token } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::ApiTokenAdded { owner, token })), + None, + ), + ApiEventType::ApiTokenRemoved { owner, token } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::ApiTokenRemoved { owner, token })), + None, + ), + ApiEventType::ApiTokenRenamed { + owner, + token, + old_name, + new_name, + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::ApiTokenRenamed { + owner, + token, + old_name, + new_name, + })), + None, + ), + ApiEventType::OpenIdAppAdded { app } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::OpenIdAppAdded { app })), + None, + ), + ApiEventType::OpenIdAppRemoved { app } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::OpenIdAppRemoved { app })), + None, + ), + ApiEventType::OpenIdAppModified { before, after } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::OpenIdAppModified { before, after })), + None, + ), + ApiEventType::OpenIdAppStateChanged { app, enabled } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::OpenIdAppStateChanged { + app, + enabled, + })), + None, + ), + ApiEventType::OpenIdProviderRemoved { provider } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::OpenIdProviderRemoved { provider })), + None, + ), + ApiEventType::OpenIdProviderModified { provider } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::OpenIdProviderModified { provider })), + None, + ), + ApiEventType::SettingsUpdated { before, after } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::SettingsUpdated { before, after })), + None, + ), + ApiEventType::SettingsUpdatedPartial { before, after } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::SettingsUpdatedPartial { + before, + after, + })), + None, + ), + ApiEventType::SettingsDefaultBrandingRestored => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::SettingsDefaultBrandingRestored)), + None, + ), + ApiEventType::GroupsBulkAssigned { users, groups } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::GroupsBulkAssigned { + users, + groups, + })), + None, + ), + ApiEventType::GroupAdded { group } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::GroupAdded { group })), + None, + ), + ApiEventType::GroupModified { before, after } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::GroupModified { before, after })), + None, + ), + ApiEventType::GroupRemoved { group } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::GroupRemoved { group })), + None, + ), + ApiEventType::GroupMemberAdded { group, user } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::GroupMemberAdded { group, user })), + None, + ), + ApiEventType::GroupMemberRemoved { group, user } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::GroupMemberRemoved { group, user })), + None, + ), + ApiEventType::GroupMembersModified { + group, + added, + removed, + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::GroupMembersModified { + group, + added, + removed, + })), + None, + ), + ApiEventType::WebHookAdded { webhook } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::WebHookAdded { webhook })), + None, + ), + ApiEventType::WebHookModified { before, after } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::WebHookModified { before, after })), + None, + ), + ApiEventType::WebHookRemoved { webhook } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::WebHookRemoved { webhook })), + None, + ), + ApiEventType::WebHookStateChanged { webhook, enabled } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::WebHookStateChanged { + webhook, + enabled, + })), + None, + ), + ApiEventType::AuthenticationKeyAdded { key } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::AuthenticationKeyAdded { key })), + None, + ), + ApiEventType::AuthenticationKeyRemoved { key } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::AuthenticationKeyRemoved { key })), + None, + ), + ApiEventType::AuthenticationKeyRenamed { + key, + old_name, + new_name, + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::AuthenticationKeyRenamed { + key, + old_name, + new_name, + })), + None, + ), + ApiEventType::EnrollmentTokenAdded { user } => ( + LoggerEvent::Enrollment(Box::new(EnrollmentEvent::TokenAdded { user })), + None, + ), + ApiEventType::PasswordChanged => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::PasswordChanged)), + None, + ), + ApiEventType::PasswordChangedByAdmin { user } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::PasswordChangedByAdmin { user })), + None, + ), + ApiEventType::PasswordReset { user } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::PasswordReset { user })), + None, + ), + ApiEventType::ClientConfigurationTokenAdded { user } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::ClientConfigurationTokenAdded { + user, + })), + None, + ), + ApiEventType::UserSnatBindingAdded { + user, + location, + binding, + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserSnatBindingAdded { + user, + binding, + })), + Some(location), + ), + ApiEventType::UserSnatBindingRemoved { + user, + location, + binding, + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserSnatBindingRemoved { + user, + binding, + })), + Some(location), + ), + ApiEventType::UserSnatBindingModified { + user, + location, + before, + after, + } => ( + LoggerEvent::Defguard(Box::new(DefguardEvent::UserSnatBindingModified { + user, + before, + after, + })), + Some(location), + ), }; - self.log_event(event.context.into(), logger_event) + self.log_event( + EventContext::from_api_context(event.context, location), + logger_event, + ) } } diff --git a/crates/defguard_event_router/src/handlers/bidi.rs b/crates/defguard_event_router/src/handlers/bidi.rs index b6a3681dc7..0dac49b404 100644 --- a/crates/defguard_event_router/src/handlers/bidi.rs +++ b/crates/defguard_event_router/src/handlers/bidi.rs @@ -1,63 +1,82 @@ use defguard_core::events::{ self, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent, PasswordResetEvent, }; -use defguard_event_logger::message::{EnrollmentEvent, LoggerEvent, VpnEvent}; +use defguard_event_logger::message::{EnrollmentEvent, EventContext, LoggerEvent, VpnEvent}; use tracing::debug; -use crate::{error::EventRouterError, EventRouter}; +use crate::{EventRouter, error::EventRouterError}; impl EventRouter { pub(crate) fn handle_bidi_event(&self, event: BidiStreamEvent) -> Result<(), EventRouterError> { debug!("Processing bidi gRPC stream event: {event:?}"); let BidiStreamEvent { context, event } = event; - let logger_event = match event { - BidiStreamEventType::Enrollment(event) => match event { - events::EnrollmentEvent::EnrollmentStarted => { - LoggerEvent::Enrollment(EnrollmentEvent::EnrollmentStarted) - } + let (logger_event, location) = match event { + BidiStreamEventType::Enrollment(event) => match *event { + events::EnrollmentEvent::EnrollmentStarted => ( + LoggerEvent::Enrollment(Box::new(EnrollmentEvent::EnrollmentStarted)), + None, + ), - events::EnrollmentEvent::EnrollmentCompleted => { - LoggerEvent::Enrollment(EnrollmentEvent::EnrollmentCompleted) - } + events::EnrollmentEvent::EnrollmentCompleted => ( + LoggerEvent::Enrollment(Box::new(EnrollmentEvent::EnrollmentCompleted)), + None, + ), - events::EnrollmentEvent::EnrollmentDeviceAdded { device } => { - LoggerEvent::Enrollment(EnrollmentEvent::EnrollmentDeviceAdded { device }) - } + events::EnrollmentEvent::EnrollmentDeviceAdded { device } => ( + LoggerEvent::Enrollment(Box::new(EnrollmentEvent::EnrollmentDeviceAdded { + device, + })), + None, + ), }, - BidiStreamEventType::PasswordReset(event) => match event { - PasswordResetEvent::PasswordResetRequested => { - LoggerEvent::Enrollment(EnrollmentEvent::PasswordResetRequested) - } - PasswordResetEvent::PasswordResetStarted => { - LoggerEvent::Enrollment(EnrollmentEvent::PasswordResetStarted) - } - PasswordResetEvent::PasswordResetCompleted => { - LoggerEvent::Enrollment(EnrollmentEvent::PasswordResetCompleted) - } + BidiStreamEventType::PasswordReset(event) => match *event { + PasswordResetEvent::PasswordResetRequested => ( + LoggerEvent::Enrollment(Box::new(EnrollmentEvent::PasswordResetRequested)), + None, + ), + PasswordResetEvent::PasswordResetStarted => ( + LoggerEvent::Enrollment(Box::new(EnrollmentEvent::PasswordResetStarted)), + None, + ), + PasswordResetEvent::PasswordResetCompleted => ( + LoggerEvent::Enrollment(Box::new(EnrollmentEvent::PasswordResetCompleted)), + None, + ), }, - BidiStreamEventType::DesktopClientMfa(event) => match event { + BidiStreamEventType::DesktopClientMfa(event) => match *event { DesktopClientMfaEvent::Connected { location, device, method, - } => LoggerEvent::Vpn(VpnEvent::ConnectedToMfaLocation { - location, - device, - method, - }), + } => ( + LoggerEvent::Vpn(Box::new(VpnEvent::ConnectedToMfaLocation { + location: location.clone(), + device, + method, + })), + Some(location), + ), DesktopClientMfaEvent::Failed { location, device, method, - } => LoggerEvent::Vpn(VpnEvent::MfaFailed { - location, - device, - method, - }), + message, + } => ( + LoggerEvent::Vpn(Box::new(VpnEvent::MfaFailed { + location: location.clone(), + device, + method, + message, + })), + Some(location), + ), }, }; - self.log_event(context.into(), logger_event) + self.log_event( + EventContext::from_bidi_context(context, location), + logger_event, + ) } } diff --git a/crates/defguard_event_router/src/handlers/grpc.rs b/crates/defguard_event_router/src/handlers/grpc.rs index 282de5c990..1fda6b4441 100644 --- a/crates/defguard_event_router/src/handlers/grpc.rs +++ b/crates/defguard_event_router/src/handlers/grpc.rs @@ -2,15 +2,15 @@ use defguard_core::events::GrpcEvent; use defguard_event_logger::message::{LoggerEvent, VpnEvent}; use tracing::debug; -use crate::{error::EventRouterError, EventRouter}; +use crate::{EventRouter, error::EventRouterError}; impl EventRouter { pub(crate) fn handle_grpc_event(&self, event: GrpcEvent) -> Result<(), EventRouterError> { debug!("Processing gRPC server event: {event:?}"); match event { - GrpcEvent::GatewayConnected => todo!(), - GrpcEvent::GatewayDisconnected => todo!(), + GrpcEvent::GatewayConnected { location: _ } => todo!(), + GrpcEvent::GatewayDisconnected { location: _ } => todo!(), GrpcEvent::ClientConnected { context, location, @@ -18,7 +18,7 @@ impl EventRouter { } => { self.log_event( context.into(), - LoggerEvent::Vpn(VpnEvent::ConnectedToLocation { location, device }), + LoggerEvent::Vpn(Box::new(VpnEvent::ConnectedToLocation { location, device })), )?; } GrpcEvent::ClientDisconnected { @@ -28,7 +28,10 @@ impl EventRouter { } => { self.log_event( context.into(), - LoggerEvent::Vpn(VpnEvent::DisconnectedFromLocation { location, device }), + LoggerEvent::Vpn(Box::new(VpnEvent::DisconnectedFromLocation { + location, + device, + })), )?; } } diff --git a/crates/defguard_event_router/src/handlers/internal.rs b/crates/defguard_event_router/src/handlers/internal.rs index d07cfb4baf..2e1e3781be 100644 --- a/crates/defguard_event_router/src/handlers/internal.rs +++ b/crates/defguard_event_router/src/handlers/internal.rs @@ -1,8 +1,8 @@ use defguard_core::events::InternalEvent; -use defguard_event_logger::message::{LoggerEvent, VpnEvent}; +use defguard_event_logger::message::{EventContext, LoggerEvent, VpnEvent}; use tracing::debug; -use crate::{error::EventRouterError, EventRouter}; +use crate::{EventRouter, error::EventRouterError}; impl EventRouter { pub(crate) fn handle_internal_event( @@ -15,8 +15,11 @@ impl EventRouter { InternalEvent::DesktopClientMfaDisconnected { context, location } => { let device = context.device.clone(); self.log_event( - context.into(), - LoggerEvent::Vpn(VpnEvent::DisconnectedFromMfaLocation { device, location }), + EventContext::from_internal_context(context, Some(location.clone())), + LoggerEvent::Vpn(Box::new(VpnEvent::DisconnectedFromMfaLocation { + device, + location, + })), ) } } diff --git a/crates/defguard_event_router/src/lib.rs b/crates/defguard_event_router/src/lib.rs index b4abcaebba..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; @@ -39,9 +28,9 @@ use defguard_event_logger::message::{EventContext, EventLoggerMessage, LoggerEve use error::EventRouterError; use events::Event; use tokio::sync::{ + Notify, broadcast::Sender, mpsc::{UnboundedReceiver, UnboundedSender}, - Notify, }; use tracing::{debug, error, info}; @@ -57,6 +46,7 @@ pub struct RouterReceiverSet { } impl RouterReceiverSet { + #[must_use] pub fn new( api: UnboundedReceiver, grpc: UnboundedReceiver, @@ -121,45 +111,33 @@ 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(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(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); }, }; - debug!("Received event: {event:?}"); + debug!("Received event"); // Route the event to the appropriate handler match event { Event::Api(api_event) => self.handle_api_event(api_event)?, - Event::Grpc(grpc_event) => self.handle_grpc_event(grpc_event)?, + 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)?, - }; + 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..fe64cfe095 --- /dev/null +++ b/crates/defguard_version/src/lib.rs @@ -0,0 +1,388 @@ +//! 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, 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 build metadata (we use it for git commit hash). +/// Returns true if v1 < v2. +#[must_use] +pub fn is_version_lower(v1: &Version, v2: &Version) -> bool { + 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.6.0-alpha1").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+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/crates/defguard_web_ui/src/assets.rs b/crates/defguard_web_ui/src/assets.rs index 7e43fa90b9..d11ea89e61 100644 --- a/crates/defguard_web_ui/src/assets.rs +++ b/crates/defguard_web_ui/src/assets.rs @@ -1,5 +1,5 @@ use axum::{ - http::{header, StatusCode, Uri}, + http::{StatusCode, Uri, header}, response::{IntoResponse, Response}, }; use rust_embed::Embed; diff --git a/crates/model_derive/src/lib.rs b/crates/model_derive/src/lib.rs index 0602281931..21f1e20cb4 100644 --- a/crates/model_derive/src/lib.rs +++ b/crates/model_derive/src/lib.rs @@ -1,8 +1,8 @@ use proc_macro::TokenStream; use quote::quote; use syn::{ - meta::parser, parse::Parser, parse_macro_input, Data, DataStruct, DeriveInput, Field, Fields, - FieldsNamed, Ident, Path, Type, TypePath, + Data, DataStruct, DeriveInput, Field, Fields, FieldsNamed, Ident, Path, Type, TypePath, + meta::parser, parse::Parser, parse_macro_input, }; /// Try to find the value of `model` attribute, e.g. `#[model(model_type)]`. @@ -123,6 +123,8 @@ pub fn derive(input: TokenStream) -> TokenStream { if field_type == "secret" { // FIXME: don't hard-code struct name cs_aliased_fields.push_str("?: SecretString\""); + } else if field_type == "ip" { + cs_aliased_fields.push_str(": IpAddr\""); } else { cs_aliased_fields.push_str(": _\""); } @@ -153,6 +155,9 @@ pub fn derive(input: TokenStream) -> TokenStream { } else if tokens == "secret" { // FIXME: hard-coded struct name return Some(quote! { &self.#name as &Option }); + } else if tokens == "ip" { + // FIXME: hard-coded struct name + return Some(quote! { &self.#name as &IpAddr }); } else { return Some(quote! { &self.#name }); } 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 ce4c1b8851..72eb2257c5 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,37 +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.2.9", + "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.27.0", - "@types/node": "^22.15.19", + "@eslint/js": "^9.34.0", + "@prettier/plugin-oxc": "^0.0.4", + "@types/node": "^22.18.0", "@types/totp-generator": "^0.0.8", - "@typescript-eslint/eslint-plugin": "^8.32.1", - "@typescript-eslint/parser": "^8.32.1", - "eslint": "^9.27.0", - "eslint-config-prettier": "^10.1.5", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-prettier": "^5.4.0", + "@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.4", "eslint-plugin-simple-import-sort": "^12.1.1", - "prettier": "^3.5.3" + "prettier": "^3.6.2" }, "dependencies": { - "@faker-js/faker": "^9.8.0", - "@playwright/test": "^1.52.0", - "@scure/base": "^1.2.5", - "@types/lodash": "^4.17.16", - "@types/pg": "^8.15.2", - "axios": "^1.9.0", - "dotenv": "^16.5.0", + "@faker-js/faker": "^9.9.0", + "@playwright/test": "^1.55.0", + "@scure/base": "^1.2.6", + "@types/lodash": "^4.17.20", + "@types/pg": "^8.15.5", + "axios": "^1.11.0", + "dotenv": "^17.2.1", "lodash": "^4.17.21", - "pg": "^8.16.0", - "playwright": "^1.52.0", + "pg": "^8.16.3", + "playwright": "^1.55.0", "totp-generator": "^1.0.0" }, "volta": { diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index caab1b45a9..9a7e0e96d6 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -9,81 +9,93 @@ importers: .: dependencies: '@faker-js/faker': - specifier: ^9.8.0 - version: 9.8.0 + specifier: ^9.9.0 + version: 9.9.0 '@playwright/test': - specifier: ^1.52.0 - version: 1.52.0 + specifier: ^1.55.0 + version: 1.55.0 '@scure/base': - specifier: ^1.2.5 - version: 1.2.5 + specifier: ^1.2.6 + version: 1.2.6 '@types/lodash': - specifier: ^4.17.16 - version: 4.17.16 + specifier: ^4.17.20 + version: 4.17.20 '@types/pg': - specifier: ^8.15.2 - version: 8.15.2 + specifier: ^8.15.5 + version: 8.15.5 axios: - specifier: ^1.9.0 - version: 1.9.0 + specifier: ^1.11.0 + version: 1.11.0 dotenv: - specifier: ^16.5.0 - version: 16.5.0 + specifier: ^17.2.1 + version: 17.2.1 lodash: specifier: ^4.17.21 version: 4.17.21 pg: - specifier: ^8.16.0 - version: 8.16.0 + specifier: ^8.16.3 + version: 8.16.3 playwright: - specifier: ^1.52.0 - version: 1.52.0 + specifier: ^1.55.0 + version: 1.55.0 totp-generator: specifier: ^1.0.0 version: 1.0.0 devDependencies: '@eslint/compat': - specifier: ^1.2.9 - version: 1.2.9(eslint@9.27.0) + 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.27.0 - version: 9.27.0 + specifier: ^9.34.0 + version: 9.34.0 + '@prettier/plugin-oxc': + specifier: ^0.0.4 + version: 0.0.4 '@types/node': - specifier: ^22.15.19 - version: 22.15.19 + 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.32.1 - version: 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.2))(eslint@9.27.0)(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.32.1 - version: 8.32.1(eslint@9.27.0)(typescript@5.8.2) + specifier: ^8.41.0 + version: 8.41.0(eslint@9.34.0)(typescript@5.8.2) eslint: - specifier: ^9.27.0 - version: 9.27.0 + specifier: ^9.34.0 + version: 9.34.0 eslint-config-prettier: - specifier: ^10.1.5 - version: 10.1.5(eslint@9.27.0) + specifier: ^10.1.8 + version: 10.1.8(eslint@9.34.0) eslint-plugin-import: - specifier: ^2.31.0 - version: 2.31.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.2))(eslint@9.27.0) + specifier: ^2.32.0 + 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.4.0 - version: 5.4.0(eslint-config-prettier@10.1.5(eslint@9.27.0))(eslint@9.27.0)(prettier@3.5.3) + 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.27.0) + version: 12.1.1(eslint@9.34.0) prettier: - specifier: ^3.5.3 - version: 3.5.3 + specifier: ^3.6.2 + version: 3.6.2 packages: + '@emnapi/core@1.4.5': + resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} + + '@emnapi/runtime@1.4.5': + resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} + + '@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==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -94,45 +106,45 @@ packages: resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/compat@1.2.9': - resolution: {integrity: sha512-gCdSY54n7k+driCadyMNv8JSPzYLeDVM/ikZRtvtROBpRdFSkS8W9A82MqsaY7lZuwL0wiapgD0NT1xT0hyJsA==} + '@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: ^9.10.0 + eslint: ^8.40 || 9 peerDependenciesMeta: eslint: optional: true - '@eslint/config-array@0.20.0': - resolution: {integrity: sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==} + '@eslint/config-array@0.21.0': + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/config-helpers@0.2.2': - resolution: {integrity: sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==} + '@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==} + '@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.27.0': - resolution: {integrity: sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==} + '@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.1': - resolution: {integrity: sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==} + '@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.8.0': - resolution: {integrity: sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==} + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} engines: {node: '>=18.0.0', npm: '>=9.0.0'} '@humanfs/core@0.19.1': @@ -155,6 +167,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@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==} engines: {node: '>= 8'} @@ -167,23 +182,122 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@pkgr/core@0.2.4': - resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} + '@oxc-parser/binding-android-arm64@0.74.0': + resolution: {integrity: sha512-lgq8TJq22eyfojfa2jBFy2m66ckAo7iNRYDdyn9reXYA3I6Wx7tgGWVx1JAp1lO+aUiqdqP/uPlDaETL9tqRcg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.74.0': + resolution: {integrity: sha512-xbY/io/hkARggbpYEMFX6CwFzb7f4iS6WuBoBeZtdqRWfIEi7sm/uYWXfyVeB8uqOATvJ07WRFC2upI8PSI83g==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.74.0': + resolution: {integrity: sha512-FIj2gAGtFaW0Zk+TnGyenMUoRu1ju+kJ/h71D77xc1owOItbFZFGa+4WSVck1H8rTtceeJlK+kux+vCjGFCl9Q==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.74.0': + resolution: {integrity: sha512-W1I+g5TJg0TRRMHgEWNWsTIfe782V3QuaPgZxnfPNmDMywYdtlzllzclBgaDq6qzvZCCQc/UhvNb37KWTCTj8A==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.74.0': + resolution: {integrity: sha512-gxqkyRGApeVI8dgvJ19SYe59XASW3uVxF1YUgkE7peW/XIg5QRAOVTFKyTjI9acYuK1MF6OJHqx30cmxmZLtiQ==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.74.0': + resolution: {integrity: sha512-jpnAUP4Fa93VdPPDzxxBguJmldj/Gpz7wTXKFzpAueqBMfZsy9KNC+0qT2uZ9HGUDMzNuKw0Se3bPCpL/gfD2Q==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.74.0': + resolution: {integrity: sha512-fcWyM7BNfCkHqIf3kll8fJctbR/PseL4RnS2isD9Y3FFBhp4efGAzhDaxIUK5GK7kIcFh1P+puIRig8WJ6IMVQ==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.74.0': + resolution: {integrity: sha512-AMY30z/C77HgiRRJX7YtVUaelKq1ex0aaj28XoJu4SCezdS8i0IftUNTtGS1UzGjGZB8zQz5SFwVy4dRu4GLwg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.74.0': + resolution: {integrity: sha512-/RZAP24TgZo4vV/01TBlzRqs0R7E6xvatww4LnmZEBBulQBU/SkypDywfriFqWuFoa61WFXPV7sLcTjJGjim/w==} + engines: {node: '>=20.0.0'} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.74.0': + resolution: {integrity: sha512-620J1beNAlGSPBD+Msb3ptvrwxu04B8iULCH03zlf0JSLy/5sqlD6qBs0XUVkUJv1vbakUw1gfVnUQqv0UTuEg==} + engines: {node: '>=20.0.0'} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.74.0': + resolution: {integrity: sha512-WBFgQmGtFnPNzHyLKbC1wkYGaRIBxXGofO0+hz1xrrkPgbxbJS1Ukva1EB8sPaVBBQ52Bdc2GjLSp721NWRvww==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.74.0': + resolution: {integrity: sha512-y4mapxi0RGqlp3t6Sm+knJlAEqdKDYrEue2LlXOka/F2i4sRN0XhEMPiSOB3ppHmvK4I2zY2XBYTsX1Fel0fAg==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-wasm32-wasi@0.74.0': + resolution: {integrity: sha512-yDS9bRDh5ymobiS2xBmjlrGdUuU61IZoJBaJC5fELdYT5LJNBXlbr3Yc6m2PWfRJwkH6Aq5fRvxAZ4wCbkGa8w==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.74.0': + resolution: {integrity: sha512-XFWY52Rfb4N5wEbMCTSBMxRkDLGbAI9CBSL24BIDywwDJMl31gHEVlmHdCDRoXAmanCI6gwbXYTrWe0HvXJ7Aw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.74.0': + resolution: {integrity: sha512-1D3x6iU2apLyfTQHygbdaNbX3nZaHu4yaXpD7ilYpoLo7f0MX0tUuoDrqJyJrVGqvyXgc0uz4yXz9tH9ZZhvvg==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.74.0': + resolution: {integrity: sha512-KOw/RZrVlHGhCXh1RufBFF7Nuo7HdY5w1lRJukM/igIl6x9qtz8QycDvZdzb4qnHO7znrPyo2sJrFJK2eKHgfQ==} + + '@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.52.0': - resolution: {integrity: sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==} + '@playwright/test@1.55.0': + resolution: {integrity: sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==} engines: {node: '>=18'} hasBin: true + '@prettier/plugin-oxc@0.0.4': + resolution: {integrity: sha512-UGXe+g/rSRbglL0FOJiar+a+nUrst7KaFmsg05wYbKiInGWP6eAj/f8A2Uobgo5KxEtb2X10zeflNH6RK2xeIQ==} + engines: {node: '>=14'} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@scure/base@1.2.5': - resolution: {integrity: sha512-9rE6EOVeIQzt5TSu4v+K523F8u6DhBsoZWPGKlnCshhlDhy0kJzUX4V+tr2dWmzF1GdekvThABoEQBGBQI7xZw==} + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@tybys/wasm-util@0.10.0': + resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} - '@types/estree@1.0.7': - resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -191,63 +305,75 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/lodash@4.17.16': - resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==} + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} - '@types/node@22.15.19': - resolution: {integrity: sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==} + '@types/node@22.18.0': + resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} - '@types/pg@8.15.2': - resolution: {integrity: sha512-+BKxo5mM6+/A1soSHBI7ufUglqYXntChLDyTbvcAn1Lawi9J7J9Ok3jt6w7I0+T/UDJ4CyhHk66+GZbwmkYxSg==} + '@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.32.1': - resolution: {integrity: sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==} + '@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.0.0 || ^8.0.0-alpha.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.32.1': - resolution: {integrity: sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==} + '@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.41.0': + resolution: {integrity: sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.32.1': - resolution: {integrity: sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==} + '@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/type-utils@8.32.1': - resolution: {integrity: sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==} + '@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 <6.0.0' + + '@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.32.1': - resolution: {integrity: sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==} + '@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.32.1': - resolution: {integrity: sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==} + '@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.32.1': - resolution: {integrity: sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==} + '@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.32.1': - resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==} + '@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: @@ -255,8 +381,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true @@ -274,8 +400,8 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-includes@3.1.8: - resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} array.prototype.findlastindex@1.2.6: @@ -305,17 +431,17 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axios@1.9.0: - resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} + axios@1.11.0: + resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -407,16 +533,16 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dotenv@16.5.0: - resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + dotenv@17.2.1: + resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==} engines: {node: '>=12'} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - es-abstract@1.23.9: - resolution: {integrity: sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==} + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -447,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' @@ -456,8 +582,8 @@ packages: eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - eslint-module-utils@2.12.0: - resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -477,8 +603,8 @@ packages: eslint-import-resolver-webpack: optional: true - eslint-plugin-import@2.31.0: - resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -487,8 +613,8 @@ packages: '@typescript-eslint/parser': optional: true - eslint-plugin-prettier@5.4.0: - resolution: {integrity: sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==} + 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' @@ -506,20 +632,20 @@ packages: peerDependencies: eslint: '>=5.0.0' - eslint-scope@8.3.0: - resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==} + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-visitor-keys@4.2.0: - resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.27.0: - resolution: {integrity: sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==} + eslint@9.34.0: + resolution: {integrity: sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -528,8 +654,8 @@ packages: jiti: optional: true - espree@10.3.0: - resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} esquery@1.6.0: @@ -586,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: '*' @@ -599,8 +725,8 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} fsevents@2.3.2: @@ -684,8 +810,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.4: - resolution: {integrity: sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} import-fresh@3.3.1: @@ -752,6 +878,10 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -899,9 +1029,6 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - obuf@1.1.2: - resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -910,6 +1037,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxc-parser@0.74.0: + resolution: {integrity: sha512-2tDN/ttU8WE6oFh8EzKNam7KE7ZXSG5uXmvX85iNzxdJfMssDWcj3gpYzZi1E04XuE7m3v1dVWl/8BE886vPGw==} + engines: {node: '>=20.0.0'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -933,39 +1064,31 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - pg-cloudflare@1.2.5: - resolution: {integrity: sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==} + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} - pg-connection-string@2.9.0: - resolution: {integrity: sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==} + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} - pg-numeric@1.0.2: - resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} - engines: {node: '>=4'} - - pg-pool@3.10.0: - resolution: {integrity: sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==} + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} peerDependencies: pg: '>=8.0' - pg-protocol@1.10.0: - resolution: {integrity: sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==} + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg-types@4.0.2: - resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} - engines: {node: '>=10'} - - pg@8.16.0: - resolution: {integrity: sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==} - engines: {node: '>= 8.0.0'} + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' peerDependenciesMeta: @@ -979,13 +1102,13 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - playwright-core@1.52.0: - resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} + playwright-core@1.55.0: + resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==} engines: {node: '>=18'} hasBin: true - playwright@1.52.0: - resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==} + playwright@1.55.0: + resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==} engines: {node: '>=18'} hasBin: true @@ -997,37 +1120,18 @@ packages: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} - postgres-array@3.0.4: - resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} - engines: {node: '>=12'} - postgres-bytea@1.0.0: resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} engines: {node: '>=0.10.0'} - postgres-bytea@3.0.0: - resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} - engines: {node: '>= 6'} - postgres-date@1.0.7: resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} engines: {node: '>=0.10.0'} - postgres-date@2.1.0: - resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} - engines: {node: '>=12'} - postgres-interval@1.2.0: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - postgres-interval@3.0.0: - resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} - engines: {node: '>=12'} - - postgres-range@1.1.4: - resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1036,8 +1140,8 @@ packages: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} - prettier@3.5.3: - resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true @@ -1136,6 +1240,10 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -1164,8 +1272,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.6: - resolution: {integrity: sha512-2pR2ubZSV64f/vqm9eLPz/KOvR9Dm+Co/5ChLgeHl0yEDRc6h5hXHoxEQH8Y5Ljycozd3p1k5TTSVdzYGkPvLw==} + synckit@0.11.11: + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} to-regex-range@5.0.1: @@ -1184,6 +1292,9 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1254,18 +1365,34 @@ packages: snapshots: - '@eslint-community/eslint-utils@4.7.0(eslint@9.27.0)': + '@emnapi/core@1.4.5': + dependencies: + '@emnapi/wasi-threads': 1.0.4 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.4.5': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.0.4': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.7.0(eslint@9.34.0)': dependencies: - eslint: 9.27.0 + eslint: 9.34.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} - '@eslint/compat@1.2.9(eslint@9.27.0)': + '@eslint/compat@1.3.2(eslint@9.34.0)': optionalDependencies: - eslint: 9.27.0 + eslint: 9.34.0 - '@eslint/config-array@0.20.0': + '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 debug: 4.4.1 @@ -1273,9 +1400,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.2.2': {} + '@eslint/config-helpers@0.3.1': {} - '@eslint/core@0.14.0': + '@eslint/core@0.15.2': dependencies: '@types/json-schema': 7.0.15 @@ -1283,7 +1410,7 @@ snapshots: dependencies: ajv: 6.12.6 debug: 4.4.1 - espree: 10.3.0 + espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 @@ -1293,16 +1420,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.27.0': {} + '@eslint/js@9.34.0': {} '@eslint/object-schema@2.1.6': {} - '@eslint/plugin-kit@0.3.1': + '@eslint/plugin-kit@0.3.5': dependencies: - '@eslint/core': 0.14.0 + '@eslint/core': 0.15.2 levn: 0.4.1 - '@faker-js/faker@9.8.0': {} + '@faker-js/faker@9.9.0': {} '@humanfs/core@0.19.1': {} @@ -1317,6 +1444,13 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.4.5 + '@emnapi/runtime': 1.4.5 + '@tybys/wasm-util': 0.10.0 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -1329,87 +1463,161 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@pkgr/core@0.2.4': {} + '@oxc-parser/binding-android-arm64@0.74.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.74.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.74.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.74.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.74.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.74.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.74.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.74.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.74.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.74.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.74.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.74.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.74.0': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.74.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.74.0': + optional: true + + '@oxc-project/types@0.74.0': {} - '@playwright/test@1.52.0': + '@pkgr/core@0.2.9': {} + + '@playwright/test@1.55.0': dependencies: - playwright: 1.52.0 + playwright: 1.55.0 + + '@prettier/plugin-oxc@0.0.4': + dependencies: + oxc-parser: 0.74.0 '@rtsao/scc@1.1.0': {} - '@scure/base@1.2.5': {} + '@scure/base@1.2.6': {} + + '@tybys/wasm-util@0.10.0': + dependencies: + tslib: 2.8.1 + optional: true - '@types/estree@1.0.7': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} - '@types/lodash@4.17.16': {} + '@types/lodash@4.17.20': {} - '@types/node@22.15.19': + '@types/node@22.18.0': dependencies: undici-types: 6.21.0 - '@types/pg@8.15.2': + '@types/pg@8.15.5': dependencies: - '@types/node': 22.15.19 - pg-protocol: 1.10.0 - pg-types: 4.0.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.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.2))(eslint@9.27.0)(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.32.1(eslint@9.27.0)(typescript@5.8.2) - '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/type-utils': 8.32.1(eslint@9.27.0)(typescript@5.8.2) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.8.2) - '@typescript-eslint/visitor-keys': 8.32.1 - eslint: 9.27.0 + '@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.4 + ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.1.0(typescript@5.8.2) typescript: 5.8.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.2)': + '@typescript-eslint/parser@8.41.0(eslint@9.34.0)(typescript@5.8.2)': + dependencies: + '@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.34.0 + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.41.0(typescript@5.8.2)': dependencies: - '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.2) - '@typescript-eslint/visitor-keys': 8.32.1 + '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.8.2) + '@typescript-eslint/types': 8.41.0 debug: 4.4.1 - eslint: 9.27.0 typescript: 5.8.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.32.1': + '@typescript-eslint/scope-manager@8.41.0': dependencies: - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/visitor-keys': 8.32.1 + '@typescript-eslint/types': 8.41.0 + '@typescript-eslint/visitor-keys': 8.41.0 - '@typescript-eslint/type-utils@8.32.1(eslint@9.27.0)(typescript@5.8.2)': + '@typescript-eslint/tsconfig-utils@8.41.0(typescript@5.8.2)': dependencies: - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.2) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.8.2) + typescript: 5.8.2 + + '@typescript-eslint/type-utils@8.41.0(eslint@9.34.0)(typescript@5.8.2)': + dependencies: + '@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.27.0 + 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.32.1': {} + '@typescript-eslint/types@8.41.0': {} - '@typescript-eslint/typescript-estree@8.32.1(typescript@5.8.2)': + '@typescript-eslint/typescript-estree@8.41.0(typescript@5.8.2)': dependencies: - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/visitor-keys': 8.32.1 + '@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 @@ -1420,27 +1628,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.32.1(eslint@9.27.0)(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.27.0) - '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.2) - eslint: 9.27.0 + '@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.32.1': + '@typescript-eslint/visitor-keys@8.41.0': dependencies: - '@typescript-eslint/types': 8.32.1 - eslint-visitor-keys: 4.2.0 + '@typescript-eslint/types': 8.41.0 + eslint-visitor-keys: 4.2.1 - acorn-jsx@5.3.2(acorn@8.14.1): + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: - acorn: 8.14.1 + acorn: 8.15.0 - acorn@8.14.1: {} + acorn@8.15.0: {} ajv@6.12.6: dependencies: @@ -1460,21 +1668,23 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-includes@3.1.8: + array-includes@3.1.9: dependencies: call-bind: 1.0.8 + call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.23.9 + es-abstract: 1.24.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 is-string: 1.1.1 + math-intrinsics: 1.1.0 array.prototype.findlastindex@1.2.6: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.23.9 + es-abstract: 1.24.0 es-errors: 1.3.0 es-object-atoms: 1.1.1 es-shim-unscopables: 1.1.0 @@ -1483,14 +1693,14 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.23.9 + es-abstract: 1.24.0 es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.23.9 + es-abstract: 1.24.0 es-shim-unscopables: 1.1.0 arraybuffer.prototype.slice@1.0.4: @@ -1498,7 +1708,7 @@ snapshots: array-buffer-byte-length: 1.0.2 call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.23.9 + es-abstract: 1.24.0 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -1511,22 +1721,22 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.9.0: + axios@1.11.0: dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.2 + follow-redirects: 1.15.11 + form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug balanced-match@1.0.2: {} - brace-expansion@1.1.11: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.1: + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -1622,7 +1832,7 @@ snapshots: dependencies: esutils: 2.0.3 - dotenv@16.5.0: {} + dotenv@17.2.1: {} dunder-proto@1.0.1: dependencies: @@ -1630,7 +1840,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - es-abstract@1.23.9: + es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 @@ -1659,7 +1869,9 @@ snapshots: is-array-buffer: 3.0.5 is-callable: 1.2.7 is-data-view: 1.0.2 + is-negative-zero: 2.0.3 is-regex: 1.2.1 + is-set: 2.0.3 is-shared-array-buffer: 1.0.4 is-string: 1.1.1 is-typed-array: 1.1.15 @@ -1674,6 +1886,7 @@ snapshots: safe-push-apply: 1.0.0 safe-regex-test: 1.1.0 set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 string.prototype.trim: 1.2.10 string.prototype.trimend: 1.0.9 string.prototype.trimstart: 1.0.8 @@ -1711,9 +1924,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.5(eslint@9.27.0): + eslint-config-prettier@10.1.8(eslint@9.34.0): dependencies: - eslint: 9.27.0 + eslint: 9.34.0 eslint-import-resolver-node@0.3.9: dependencies: @@ -1723,28 +1936,28 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.27.0): + 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.32.1(eslint@9.27.0)(typescript@5.8.2) - eslint: 9.27.0 + '@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.31.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.2))(eslint@9.27.0): + 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.8 + array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.27.0 + eslint: 9.34.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint@9.27.0) + 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 @@ -1756,57 +1969,57 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.32.1(eslint@9.27.0)(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.4.0(eslint-config-prettier@10.1.5(eslint@9.27.0))(eslint@9.27.0)(prettier@3.5.3): + 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.27.0 - prettier: 3.5.3 + eslint: 9.34.0 + prettier: 3.6.2 prettier-linter-helpers: 1.0.0 - synckit: 0.11.6 + synckit: 0.11.11 optionalDependencies: - eslint-config-prettier: 10.1.5(eslint@9.27.0) + eslint-config-prettier: 10.1.8(eslint@9.34.0) - eslint-plugin-simple-import-sort@12.1.1(eslint@9.27.0): + eslint-plugin-simple-import-sort@12.1.1(eslint@9.34.0): dependencies: - eslint: 9.27.0 + eslint: 9.34.0 - eslint-scope@8.3.0: + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 eslint-visitor-keys@3.4.3: {} - eslint-visitor-keys@4.2.0: {} + eslint-visitor-keys@4.2.1: {} - eslint@9.27.0: + eslint@9.34.0: dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0) '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.20.0 - '@eslint/config-helpers': 0.2.2 - '@eslint/core': 0.14.0 + '@eslint/config-array': 0.21.0 + '@eslint/config-helpers': 0.3.1 + '@eslint/core': 0.15.2 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.27.0 - '@eslint/plugin-kit': 0.3.1 + '@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 - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.1 escape-string-regexp: 4.0.0 - eslint-scope: 8.3.0 - eslint-visitor-keys: 4.2.0 - espree: 10.3.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 esquery: 1.6.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 @@ -1824,11 +2037,11 @@ snapshots: transitivePeerDependencies: - supports-color - espree@10.3.0: + espree@10.4.0: dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) - eslint-visitor-keys: 4.2.0 + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 esquery@1.6.0: dependencies: @@ -1882,17 +2095,18 @@ 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.2: + form-data@4.0.4: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 + hasown: 2.0.2 mime-types: 2.1.35 fsevents@2.3.2: @@ -1978,7 +2192,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.4: {} + ignore@7.0.5: {} import-fresh@3.3.1: dependencies: @@ -2052,6 +2266,8 @@ snapshots: is-map@2.0.3: {} + is-negative-zero@2.0.3: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -2152,11 +2368,11 @@ snapshots: minimatch@3.1.2: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.12 minimatch@9.0.5: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.0.2 minimist@1.2.8: {} @@ -2181,14 +2397,14 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.23.9 + es-abstract: 1.24.0 es-object-atoms: 1.1.1 object.groupby@1.0.3: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.23.9 + es-abstract: 1.24.0 object.values@1.2.1: dependencies: @@ -2197,8 +2413,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - obuf@1.1.2: {} - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2214,6 +2428,26 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-parser@0.74.0: + dependencies: + '@oxc-project/types': 0.74.0 + optionalDependencies: + '@oxc-parser/binding-android-arm64': 0.74.0 + '@oxc-parser/binding-darwin-arm64': 0.74.0 + '@oxc-parser/binding-darwin-x64': 0.74.0 + '@oxc-parser/binding-freebsd-x64': 0.74.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.74.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.74.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.74.0 + '@oxc-parser/binding-linux-arm64-musl': 0.74.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.74.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.74.0 + '@oxc-parser/binding-linux-x64-gnu': 0.74.0 + '@oxc-parser/binding-linux-x64-musl': 0.74.0 + '@oxc-parser/binding-wasm32-wasi': 0.74.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.74.0 + '@oxc-parser/binding-win32-x64-msvc': 0.74.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -2232,20 +2466,18 @@ snapshots: path-parse@1.0.7: {} - pg-cloudflare@1.2.5: + pg-cloudflare@1.2.7: optional: true - pg-connection-string@2.9.0: {} + pg-connection-string@2.9.1: {} pg-int8@1.0.1: {} - pg-numeric@1.0.2: {} - - pg-pool@3.10.0(pg@8.16.0): + pg-pool@3.10.1(pg@8.16.3): dependencies: - pg: 8.16.0 + pg: 8.16.3 - pg-protocol@1.10.0: {} + pg-protocol@1.10.3: {} pg-types@2.2.0: dependencies: @@ -2255,25 +2487,15 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg-types@4.0.2: + pg@8.16.3: dependencies: - pg-int8: 1.0.1 - pg-numeric: 1.0.2 - postgres-array: 3.0.4 - postgres-bytea: 3.0.0 - postgres-date: 2.1.0 - postgres-interval: 3.0.0 - postgres-range: 1.1.4 - - pg@8.16.0: - dependencies: - pg-connection-string: 2.9.0 - pg-pool: 3.10.0(pg@8.16.0) - pg-protocol: 1.10.0 + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: - pg-cloudflare: 1.2.5 + pg-cloudflare: 1.2.7 pgpass@1.0.5: dependencies: @@ -2281,11 +2503,11 @@ snapshots: picomatch@2.3.1: {} - playwright-core@1.52.0: {} + playwright-core@1.55.0: {} - playwright@1.52.0: + playwright@1.55.0: dependencies: - playwright-core: 1.52.0 + playwright-core: 1.55.0 optionalDependencies: fsevents: 2.3.2 @@ -2293,33 +2515,21 @@ snapshots: postgres-array@2.0.0: {} - postgres-array@3.0.4: {} - postgres-bytea@1.0.0: {} - postgres-bytea@3.0.0: - dependencies: - obuf: 1.1.2 - postgres-date@1.0.7: {} - postgres-date@2.1.0: {} - postgres-interval@1.2.0: dependencies: xtend: 4.0.2 - postgres-interval@3.0.0: {} - - postgres-range@1.1.4: {} - prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: dependencies: fast-diff: 1.3.0 - prettier@3.5.3: {} + prettier@3.6.2: {} proxy-from-env@1.1.0: {} @@ -2331,7 +2541,7 @@ snapshots: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - es-abstract: 1.23.9 + es-abstract: 1.24.0 es-errors: 1.3.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 @@ -2442,13 +2652,18 @@ snapshots: split2@4.2.0: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.23.9 + es-abstract: 1.24.0 es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 @@ -2475,9 +2690,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.6: + synckit@0.11.11: dependencies: - '@pkgr/core': 0.2.4 + '@pkgr/core': 0.2.9 to-regex-range@5.0.1: dependencies: @@ -2498,6 +2713,9 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@2.8.1: + optional: true + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index 46611bba15..59026a1998 100644 --- a/e2e/tests/auth.spec.ts +++ b/e2e/tests/auth.spec.ts @@ -11,9 +11,8 @@ 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 { waitForPromise } from '../utils/waitForPromise'; import { waitForRoute } from '../utils/waitForRoute'; test.describe('Test user authentication', () => { @@ -24,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); @@ -82,7 +79,7 @@ test.describe('Test user authentication', () => { digits: 6, period: 60, }); - await page.getByTestId('field-code').type(code); + await page.getByTestId('field-code').fill(code); await page.locator('button[type="submit"]').click(); await waitForRoute(page, routes.me); }); @@ -93,8 +90,8 @@ test.describe('Test user authentication', () => { await disableUser(browser, testUser); await page.goto(routes.base); await waitForRoute(page, routes.auth.login); - await page.getByTestId('login-form-username').type(testUser.username); - await page.getByTestId('login-form-password').type(testUser.password); + await page.getByTestId('login-form-username').fill(testUser.username); + await page.getByTestId('login-form-password').fill(testUser.password); const responsePromise = page.waitForResponse('**/auth'); await page.getByTestId('login-form-submit').click(); const response = await responsePromise; @@ -109,13 +106,9 @@ test.describe('Test user authentication', () => { await waitForRoute(page, routes.me); expect(page.url()).toBe(routes.base + routes.me); await disableUser(browser, testUser); - // The user should be logged out when the admin disables him - await waitForPromise(2000); - const responsePromise = page.waitForResponse('**/user/' + testUser.username); + const responsePromise = page.waitForResponse((resp) => resp.status() === 401); await page.locator('a[href="/me"]').click(); - const response = await responsePromise; - expect(response.status()).toBe(401); - expect(page.url()).toBe(routes.base + routes.auth.login); + await responsePromise; }); }); @@ -127,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 a4cf013b60..5d3d2a5fc4 100644 --- a/e2e/tests/externalopenid.spec.ts +++ b/e2e/tests/externalopenid.spec.ts @@ -6,10 +6,10 @@ import { apiCreateUser } from '../utils/api/users'; import { loginBasic } from '../utils/controllers/login'; import { logout } from '../utils/controllers/logout'; import { copyOpenIdClientIdAndSecret } from '../utils/controllers/openid/copyClientId'; -import { CreateExternalProvider } from '../utils/controllers/openid/createExternalProvider'; +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'; @@ -20,8 +20,8 @@ test.describe('External OIDC.', () => { const client: OpenIdClient = { name: 'test 01', redirectURL: [ - 'http://localhost:8000/auth/callback', - 'http://localhost:8080/openid/callback', + `${testsConfig.BASE_URL}/auth/callback`, + `${testsConfig.ENROLLMENT_URL}/openid/callback`, ], scopes: ['openid', 'profile', 'email'], }; @@ -42,16 +42,12 @@ test.describe('External OIDC.', () => { ); const context = await browser.newContext(); const page = await context.newPage(); - await CreateExternalProvider(browser, client); + await createExternalProvider(browser, client); await loginBasic(page, defaultUserAdmin); await apiCreateUser(page, testUser); await logout(page); await createNetwork(browser, testNetwork); - context.close(); - }); - - test.afterAll(() => { - dockerDown(); + await context.close(); }); test('Login through external oidc.', async ({ page }) => { @@ -84,16 +80,17 @@ test.describe('External OIDC.', () => { await page.getByTestId('login-form-password').fill(testUser.password); await page.getByTestId('login-form-submit').click(); await page.getByTestId('openid-allow').click(); - const instanceUrlBox = page - .locator('div') - .filter({ hasText: /^Instance URL$/ }) - .getByRole('textbox'); + const instanceUrlBoxText = await page + .locator('div.copy-field div.list-cell-text ') + .first() + .textContent(); + expect(instanceUrlBoxText).toBe(testsConfig.ENROLLMENT_URL + '/'); - expect(await instanceUrlBox.inputValue()).toBe('http://localhost:8080/'); - const instanceTokenBox = page - .locator('div') - .filter({ hasText: /^Token$/ }) - .getByRole('textbox'); - expect((await instanceTokenBox.inputValue()).length).toBeGreaterThan(1); + const instanceTokenBoxText = await page + .locator('div.copy-field div.list-cell-text ') + .nth(1) + .textContent(); + expect(instanceTokenBoxText).toBeDefined(); + expect(instanceTokenBoxText?.length).toBeGreaterThan(1); }); }); diff --git a/e2e/tests/externalopenidmfa.spec.ts b/e2e/tests/externalopenidmfa.spec.ts new file mode 100644 index 0000000000..579fc2381e --- /dev/null +++ b/e2e/tests/externalopenidmfa.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from '@playwright/test'; + +import { defaultUserAdmin, testsConfig, testUserTemplate } from '../config'; +import { NetworkForm, OpenIdClient, User } from '../types'; +import { apiCreateUser, apiGetUserProfile } from '../utils/api/users'; +import { loginBasic } from '../utils/controllers/login'; +import { logout } from '../utils/controllers/logout'; +import { copyOpenIdClientIdAndSecret } from '../utils/controllers/openid/copyClientId'; +import { createExternalProvider } from '../utils/controllers/openid/createExternalProvider'; +import { CreateOpenIdClient } from '../utils/controllers/openid/createOpenIdClient'; +import { createDevice } from '../utils/controllers/vpn/createDevice'; +import { createNetwork } from '../utils/controllers/vpn/createNetwork'; +import { dockerRestart } from '../utils/docker'; +import { waitForBase } from '../utils/waitForBase'; +import { waitForPromise } from '../utils/waitForPromise'; + +test.describe('External OIDC.', () => { + const testUser: User = { ...testUserTemplate, username: 'test' }; + + const client: OpenIdClient = { + name: 'test 01', + redirectURL: [`${testsConfig.ENROLLMENT_URL}/openid/mfa/callback`], + scopes: ['openid', 'profile', 'email'], + }; + + const testNetwork: NetworkForm = { + name: 'test network', + address: '10.10.10.1/24', + endpoint: '127.0.0.1', + port: '5055', + location_mfa_mode: 'external', + }; + + test.beforeEach(async ({ browser }) => { + dockerRestart(); + await CreateOpenIdClient(browser, client); + [client.clientID, client.clientSecret] = await copyOpenIdClientIdAndSecret( + browser, + client.name, + ); + const context = await browser.newContext(); + const page = await context.newPage(); + await createExternalProvider(browser, client); + await loginBasic(page, defaultUserAdmin); + await apiCreateUser(page, testUser); + await logout(page); + await createNetwork(browser, testNetwork); + await context.close(); + }); + + test('Complete client MFA through external OpenID', async ({ page, browser }) => { + await waitForBase(page); + const mfaStartUrl = `${testsConfig.ENROLLMENT_URL}/api/v1/client-mfa/start`; + await createDevice(browser, testUser, { + name: 'test', + }); + await loginBasic(page, testUser); + const testUserProfile = await apiGetUserProfile(page, testUser.username); + expect(testUserProfile.devices.length).toBe(1); + const createdDevice = testUserProfile.devices[0]; + const pubkey = createdDevice.wireguard_pubkey; + const data = { + method: 2, + pubkey: pubkey, + location_id: 1, + }; + const response = await page.request.post(mfaStartUrl, { + data: data, + }); + expect(response.ok()).toBeTruthy(); + const { token } = await response.json(); + expect(token).toBeDefined(); + expect(token.length).toBeGreaterThan(0); + + const preconditionResponse = await page.request.post( + testsConfig.ENROLLMENT_URL + '/api/v1/client-mfa/finish', + { + data: { + token: token, + }, + }, + ); + expect(preconditionResponse.status()).toBe(428); + + const url = testsConfig.ENROLLMENT_URL + '/openid/mfa' + `?token=${token}`; + await page.goto(url); + await waitForPromise(2000); + await page.getByTestId('openid-allow').click(); + await waitForPromise(2000); + + const finish = testsConfig.ENROLLMENT_URL + '/api/v1/client-mfa/finish'; + const finishResponse = await page.request.post(finish, { + data: { + token: token, + }, + }); + expect(finishResponse.ok()).toBeTruthy(); + const finishData = await finishResponse.json(); + expect(finishData.preshared_key).toBeDefined(); + expect(finishData.preshared_key.length).toBeGreaterThan(0); + }); +}); 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 f6636f53c1..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'; @@ -60,7 +58,7 @@ test.describe('Network devices', () => { // Check if the device is really there await page.goto(routes.base + routes.admin.devices); - const deviceList = await page.locator('#devices-page-devices-list').first(); + const deviceList = page.locator('#devices-page-devices-list').first(); const deviceRows = deviceList.locator('.device-row'); await expect(deviceRows).toHaveCount(1); const deviceRow = await getDeviceRow({ page, deviceName }); diff --git a/e2e/tests/vpn/wizard.spec.ts b/e2e/tests/vpn/wizard.spec.ts index b075133cb3..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; @@ -47,8 +43,8 @@ test.describe('Setup VPN (wizard) ', () => { const navBack = page.getByTestId('wizard-back'); await page.getByTestId('setup-option-import').click(); await navNext.click(); - await page.getByTestId('field-name').type('test network'); - await page.getByTestId('field-endpoint').type('127.0.0.1:5051'); + await page.getByTestId('field-name').fill('test network'); + await page.getByTestId('field-endpoint').fill('127.0.0.1:5051'); const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByTestId('upload-config').click(); const responseImportConfigPromise = page.waitForResponse('**/import'); diff --git a/e2e/types.ts b/e2e/types.ts index 00a10bf9d1..966f45e8b4 100644 --- a/e2e/types.ts +++ b/e2e/types.ts @@ -69,6 +69,7 @@ export type NetworkForm = { port: string; allowed_ips?: string; dns?: string; + location_mfa_mode?: string; }; export type DeviceForm = { diff --git a/e2e/utils/controllers/createUser.ts b/e2e/utils/controllers/createUser.ts index d09265f3db..b84b1e5c4c 100644 --- a/e2e/utils/controllers/createUser.ts +++ b/e2e/utils/controllers/createUser.ts @@ -20,12 +20,12 @@ export const createUser = async ( await page.getByTestId('add-user').click(); const formElement = page.getByTestId('add-user-form'); await formElement.waitFor({ state: 'visible' }); - await formElement.getByTestId('field-username').type(user.username); - await formElement.getByTestId('field-password').type(user.password); - await formElement.getByTestId('field-first_name').type(user.firstName); - await formElement.getByTestId('field-last_name').type(user.lastName); - await formElement.getByTestId('field-email').type(user.mail); - await formElement.getByTestId('field-phone').type(user.phone); + await formElement.getByTestId('field-username').fill(user.username); + await formElement.getByTestId('field-password').fill(user.password); + await formElement.getByTestId('field-first_name').fill(user.firstName); + await formElement.getByTestId('field-last_name').fill(user.lastName); + await formElement.getByTestId('field-email').fill(user.mail); + await formElement.getByTestId('field-phone').fill(user.phone); await formElement.locator('button[type="submit"]').click(); await formElement.waitFor({ state: 'hidden', timeout: 2000 }); if (groups) { diff --git a/e2e/utils/controllers/enrollment.ts b/e2e/utils/controllers/enrollment.ts index ae5732e8d0..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'; @@ -28,11 +27,11 @@ export const createUserEnrollment = async ( await page.getByTestId('add-user').click(); const formElement = page.getByTestId('add-user-form'); await formElement.waitFor({ state: 'visible' }); - await formElement.getByTestId('field-username').type(user.username); - await formElement.getByTestId('field-first_name').type(user.firstName); - await formElement.getByTestId('field-last_name').type(user.lastName); - await formElement.getByTestId('field-email').type(user.mail); - await formElement.getByTestId('field-phone').type(user.phone); + await formElement.getByTestId('field-username').fill(user.username); + await formElement.getByTestId('field-first_name').fill(user.firstName); + await formElement.getByTestId('field-last_name').fill(user.lastName); + await formElement.getByTestId('field-email').fill(user.mail); + await formElement.getByTestId('field-phone').fill(user.phone); await formElement.getByTestId('field-enable_enrollment').click(); await formElement.locator('button[type="submit"]').click(); waitForPromise(2000); @@ -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(); @@ -62,7 +62,7 @@ export const selectEnrollment = async (page: Page) => { export const setToken = async (token: string, page: Page) => { const formElement = page.getByTestId('enrollment-token-form'); - await formElement.getByTestId('field-token').type(token); + await formElement.getByTestId('field-token').fill(token); await page.getByTestId('enrollment-token-submit-button').click(); }; @@ -83,12 +83,12 @@ export const validateData = async (user: User, page: Page) => { export const setPassword = async (page: Page) => { const formElement = page.getByTestId('enrollment-password-form'); - await formElement.getByTestId('field-password').type(password); - await formElement.getByTestId('field-repeat').type(password); + await formElement.getByTestId('field-password').fill(password); + await formElement.getByTestId('field-repeat').fill(password); }; export const createDevice = async (page: Page) => { const formElement = page.getByTestId('enrollment-device-form'); - await formElement.getByTestId('field-name').type('test'); + await formElement.getByTestId('field-name').fill('test'); await formElement.locator('button[type="submit"]').click(); }; diff --git a/e2e/utils/controllers/login.ts b/e2e/utils/controllers/login.ts index d16c5e2324..aa8c7d6f1c 100644 --- a/e2e/utils/controllers/login.ts +++ b/e2e/utils/controllers/login.ts @@ -15,8 +15,8 @@ type AuthInfo = User | Pick; export const loginBasic = async (page: Page, userInfo: AuthInfo) => { await page.goto(testsConfig.BASE_URL); await waitForRoute(page, routes.auth.login); - await page.getByTestId('login-form-username').type(userInfo.username); - await page.getByTestId('login-form-password').type(userInfo.password); + await page.getByTestId('login-form-username').fill(userInfo.username); + await page.getByTestId('login-form-password').fill(userInfo.password); const responsePromise = page.waitForResponse('**/auth'); await page.getByTestId('login-form-submit').click(); const response = await responsePromise; @@ -47,6 +47,6 @@ export const loginRecoveryCodes = async ( waitUntil: 'networkidle', }); await page.getByTestId('field-code').clear(); - await page.getByTestId('field-code').type(code.trim(), { delay: 100 }); + await page.getByTestId('field-code').fill(code.trim(), { delay: 100 }); await page.locator('button[type="submit"]').click(); }; diff --git a/e2e/utils/controllers/mfa/enableEmail.ts b/e2e/utils/controllers/mfa/enableEmail.ts index 0441ce7773..0faaeffeb7 100644 --- a/e2e/utils/controllers/mfa/enableEmail.ts +++ b/e2e/utils/controllers/mfa/enableEmail.ts @@ -61,7 +61,7 @@ export const enableEmailMFA = async ( digits: 6, period: 60, }); - await page.getByTestId('field-code').type(code); + await page.getByTestId('field-code').fill(code); await formElement.locator('button[type="submit"]').click(); await formElement.waitFor({ state: 'detached', timeout: 1000 }); const recovery = await acceptRecovery(page); diff --git a/e2e/utils/controllers/mfa/enableTOTP.ts b/e2e/utils/controllers/mfa/enableTOTP.ts index 1ede2f3c71..ba9a9d57c8 100644 --- a/e2e/utils/controllers/mfa/enableTOTP.ts +++ b/e2e/utils/controllers/mfa/enableTOTP.ts @@ -32,7 +32,7 @@ export const enableTOTP = async ( const totpSecret = await getPageClipboard(page); const { otp: token } = TOTP.generate(totpSecret); const totpForm = page.getByTestId('register-totp-form'); - await totpForm.getByTestId('field-code').type(token); + await totpForm.getByTestId('field-code').fill(token); await totpForm.locator('button[type="submit"]').click(); await totpForm.waitFor({ state: 'hidden' }); const recovery = await acceptRecovery(page); diff --git a/e2e/utils/controllers/openid/createExternalProvider.ts b/e2e/utils/controllers/openid/createExternalProvider.ts index 99c3456e29..277c7e5e5c 100644 --- a/e2e/utils/controllers/openid/createExternalProvider.ts +++ b/e2e/utils/controllers/openid/createExternalProvider.ts @@ -5,7 +5,7 @@ import { OpenIdClient } from '../../../types'; import { waitForBase } from '../../waitForBase'; import { loginBasic } from '../login'; -export const CreateExternalProvider = async (browser: Browser, client: OpenIdClient) => { +export const createExternalProvider = async (browser: Browser, client: OpenIdClient) => { const context = await browser.newContext(); const page = await context.newPage(); await waitForBase(page); @@ -19,4 +19,5 @@ export const CreateExternalProvider = async (browser: Browser, client: OpenIdCli await page.getByTestId('field-client_secret').fill(client.clientSecret || ''); await page.getByTestId('field-display_name').fill(client.name); await page.getByRole('button', { name: 'Save changes' }).click(); + await context.close(); }; diff --git a/e2e/utils/controllers/openid/createOpenIdClient.ts b/e2e/utils/controllers/openid/createOpenIdClient.ts index b7e319a8be..d03c598710 100644 --- a/e2e/utils/controllers/openid/createOpenIdClient.ts +++ b/e2e/utils/controllers/openid/createOpenIdClient.ts @@ -16,7 +16,7 @@ export const CreateOpenIdClient = async (browser: Browser, client: OpenIdClient) const modalElement = page.locator('#openid-client-modal'); await modalElement.waitFor({ state: 'visible' }); const modalForm = modalElement.locator('form'); - await modalForm.getByTestId('field-name').type(client.name); + await modalForm.getByTestId('field-name').fill(client.name); const urls = client.redirectURL.length; for (let i = 0; i < urls; i++) { const isLast = i === urls - 1; diff --git a/e2e/utils/controllers/passwordReset.ts b/e2e/utils/controllers/passwordReset.ts index 6271eb4a3a..fce9c4bdc4 100644 --- a/e2e/utils/controllers/passwordReset.ts +++ b/e2e/utils/controllers/passwordReset.ts @@ -6,12 +6,12 @@ export const selectPasswordReset = async (page: Page) => { }; export const setEmail = async (token: string, page: Page) => { - await page.getByTestId('field-email').type(token); + await page.getByTestId('field-email').fill(token); await page.getByTestId('password-reset-email-submit-button').click(); }; export const setPassword = async (password: string, page: Page) => { - await page.getByTestId('field-password').type(password); - await page.getByTestId('field-repeat').type(password); + await page.getByTestId('field-password').fill(password); + await page.getByTestId('field-repeat').fill(password); await page.getByTestId('password-reset-submit').click(); }; diff --git a/e2e/utils/controllers/profile.ts b/e2e/utils/controllers/profile.ts index e3e00e60aa..1048e8f98a 100644 --- a/e2e/utils/controllers/profile.ts +++ b/e2e/utils/controllers/profile.ts @@ -8,10 +8,10 @@ export const changePassword = async (page: Page, currentPassword: string) => { await page.getByTestId('button-change-password').click(); const formElement = page.getByTestId('change-self-password-form'); await formElement.waitFor({ state: 'visible' }); - await formElement.getByTestId('field-old_password').type(currentPassword); + await formElement.getByTestId('field-old_password').fill(currentPassword); const newPassword = 'Test1234#$%'; - await formElement.getByTestId('field-new_password').type(newPassword); - await formElement.getByTestId('field-repeat').type(newPassword); + await formElement.getByTestId('field-new_password').fill(newPassword); + await formElement.getByTestId('field-repeat').fill(newPassword); await formElement.locator('button[type="submit"]').click(); await formElement.waitFor({ state: 'hidden', timeout: 2000 }); return newPassword; @@ -23,8 +23,8 @@ export const changePasswordByAdmin = async (page: Page) => { const formElement = page.getByTestId('change-password-admin-form'); await formElement.waitFor({ state: 'visible' }); const newPassword = 'Test1234#$%'; - await formElement.getByTestId('field-new_password').type(newPassword); - await formElement.getByTestId('field-repeat').type(newPassword); + await formElement.getByTestId('field-new_password').fill(newPassword); + await formElement.getByTestId('field-repeat').fill(newPassword); await formElement.locator('button[type="submit"]').click(); await formElement.waitFor({ state: 'hidden', timeout: 2000 }); return newPassword; diff --git a/e2e/utils/controllers/vpn/createDevice.ts b/e2e/utils/controllers/vpn/createDevice.ts index 241f6512c8..8f0c712ae5 100644 --- a/e2e/utils/controllers/vpn/createDevice.ts +++ b/e2e/utils/controllers/vpn/createDevice.ts @@ -15,21 +15,21 @@ export const createDevice = async (browser: Browser, user: User, device: DeviceF // chose manual const choiceCard = page.locator('#setup-method-step'); await choiceCard.waitFor({ state: 'visible' }); - await choiceCard.getByTestId('choice-manual').click(); - await page.getByTestId('next-step').click(); + await choiceCard.getByTestId('add-device-method-native-wg').click(); + await page.getByTestId('nav-next-step').click(); const configStep = page.locator('#add-device-setup-step'); await configStep.waitFor({ state: 'visible' }); // fill form await configStep.getByTestId('field-name').clear(); - await configStep.getByTestId('field-name').type(device.name); + await configStep.getByTestId('field-name').fill(device.name); if (device.pubKey && device.pubKey.length) { await page.locator('.toggle-option').nth(1).click(); await configStep.getByTestId('field-publicKey').clear(); - await configStep.getByTestId('field-publicKey').type(device.pubKey); + await configStep.getByTestId('field-publicKey').fill(device.pubKey); } // await response const responsePromise = page.waitForResponse('**/device/**'); - await page.getByTestId('next-step').click(); + await page.getByTestId('nav-next-step').click(); const response = await responsePromise; expect(response.status()).toBe(201); await context.close(); diff --git a/e2e/utils/controllers/vpn/createNetwork.ts b/e2e/utils/controllers/vpn/createNetwork.ts index 7351a4a4e5..da01269199 100644 --- a/e2e/utils/controllers/vpn/createNetwork.ts +++ b/e2e/utils/controllers/vpn/createNetwork.ts @@ -15,11 +15,38 @@ export const createNetwork = async (browser: Browser, network: NetworkForm) => { const navNext = page.getByTestId('wizard-next'); await page.getByTestId('setup-option-manual').click(); await navNext.click(); - for (const key of Object.keys(network)) { + + // fill form + for (const key of Object.keys(network).filter((key) => key !== 'location_mfa_mode')) { const field = page.getByTestId(`field-${key}`); await field.clear(); await field.type(network[key]); } + // select location MFA mode + if (network.location_mfa_mode) { + const mfaModeSelect = page.locator('div.location-mfa-mode-select'); + 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(); + } + const responseCreateNetworkPromise = page.waitForResponse('**/network'); await navNext.click(); const response = await responseCreateNetworkPromise; diff --git a/e2e/utils/controllers/vpn/createNetworkDevice.ts b/e2e/utils/controllers/vpn/createNetworkDevice.ts index 20f03e801b..638dfad4f7 100644 --- a/e2e/utils/controllers/vpn/createNetworkDevice.ts +++ b/e2e/utils/controllers/vpn/createNetworkDevice.ts @@ -12,7 +12,7 @@ export const getDeviceRow = async ({ page: Page; deviceName: string; }) => { - const deviceList = await page.locator('#devices-page-devices-list').first(); + const deviceList = page.locator('#devices-page-devices-list').first(); const deviceRows = await deviceList.locator('.device-row').all(); const row = deviceRows.find(async (val) => { if ((await val.innerText()) === deviceName) { @@ -35,7 +35,7 @@ export const doAction = async ({ action: string; }) => { await deviceRow.locator('.edit-button').click(); - const editMenu = await page.locator('.edit-button-floating-ui').first(); + const editMenu = page.locator('.edit-button-floating-ui').first(); await editMenu.getByRole('button', { name: action }).click(); }; @@ -47,20 +47,24 @@ export const createNetworkDevice = async ( const context = await browser.newContext(); const page = await context.newPage(); await loginBasic(page, user); - await page.goto(routes.base + routes.admin.devices); + await page.goto(routes.base + routes.admin.devices, { + waitUntil: 'networkidle', + }); await page.getByRole('button', { name: 'Add new' }).click(); const configCard = page.locator('#add-standalone-device-modal'); - await configCard.getByRole('button', { name: 'Select' }).click(); + await configCard.waitFor({ state: 'visible' }); + // select native-wg method + await page.getByTestId('standalone-device-choice-card-manual').click(); await configCard.getByRole('button', { name: 'Next' }).click(); - const deviceNameInput = await configCard.getByTestId('field-name'); + const deviceNameInput = configCard.getByTestId('field-name'); await deviceNameInput.fill(device.name); if (device.description && device.description.length > 0) { - const deviceDescriptionInput = await page.getByTestId('field-description'); + const deviceDescriptionInput = page.getByTestId('field-description'); await deviceDescriptionInput.fill(device.description); } if (device.pubKey && device.pubKey.length) { await configCard.locator('.toggle-option').nth(1).click(); - const devicePublicKeyInput = await configCard.getByTestId('field-wireguard_pubkey'); + const devicePublicKeyInput = configCard.getByTestId('field-wireguard_pubkey'); await devicePublicKeyInput.fill(device.pubKey); } const responsePromise = page.waitForResponse('**/device/network'); @@ -82,10 +86,10 @@ export const startNetworkDeviceEnrollment = async ( await page.getByRole('button', { name: 'Add new' }).click(); const configCard = page.locator('#add-standalone-device-modal'); await configCard.getByRole('button', { name: 'Next' }).click(); - const deviceNameInput = await configCard.getByTestId('field-name'); + const deviceNameInput = configCard.getByTestId('field-name'); await deviceNameInput.fill(device.name); if (device.description && device.description.length > 0) { - const deviceDescriptionInput = await page.getByTestId('field-description'); + const deviceDescriptionInput = page.getByTestId('field-description'); await deviceDescriptionInput.fill(device.description); } const responsePromise = page.waitForResponse('**/device/network'); 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 41ce99af79..abf59ab737 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1748929857, - "narHash": "sha256-lcZQ8RhsmhsK8u7LIFsJhsLh/pzR9yZ8yqpTzyGdj+Q=", + "lastModified": 1757347588, + "narHash": "sha256-tLdkkC6XnsY9EOZW9TlpesTclELy8W7lL2ClL+nma8o=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c2a03962b8e24e669fb37b7df10e7c79531ff1a4", + "rev": "b599843bad24621dcaa5ab60dac98f9b0eb1cabe", "type": "github" }, "original": { @@ -48,11 +48,11 @@ ] }, "locked": { - "lastModified": 1749004659, - "narHash": "sha256-zaZrcC5UwHPGkgfnhTPx5sZfSSnUJdvYHhgex10RadQ=", + "lastModified": 1757471515, + "narHash": "sha256-0+rSzNsYindDWjO9VVULKGjXlPsQV6IDjRU5G3SwI9U=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c52e346aedfa745564599558a096e88f9a5557f9", + "rev": "aecf31120156fe47a7d1992aa814052910178fca", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index ec493ea949..f2e6a984c1 100644 --- a/flake.nix +++ b/flake.nix @@ -28,7 +28,7 @@ }; # define shared build inputs nativeBuildInputs = with pkgs; [rustToolchain pkg-config]; - buildInputs = with pkgs; [openssl protobuf curl nodejs_22 pnpm]; + buildInputs = with pkgs; [openssl protobuf curl nodejs_24 pnpm]; in { devShells.default = pkgs.mkShell { inherit nativeBuildInputs buildInputs; @@ -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 new file mode 100644 index 0000000000..477cfea388 --- /dev/null +++ b/images/ami/core.pkr.hcl @@ -0,0 +1,62 @@ +packer { + required_plugins { + amazon = { + version = ">= 1.2.8" + source = "github.com/hashicorp/amazon" + } + } +} + +variable "package_version" { + type = string +} + +variable "region" { + type = string + default = "eu-north-1" +} + +variable "instance_type" { + type = string + default = "t3.micro" +} + +source "amazon-ebs" "defguard-core" { + ami_name = "defguard-core-${var.package_version}-amd64" + instance_type = var.instance_type + region = var.region + source_ami_filter { + filters = { + name = "debian-13-amd64-*" + root-device-type = "ebs" + virtualization-type = "hvm" + } + most_recent = true + owners = ["136693071363"] + } + ssh_username = "admin" +} + +build { + name = "defguard-core" + sources = [ + "source.amazon-ebs.defguard-core" + ] + + provisioner "file" { + source = "defguard-${var.package_version}-x86_64-unknown-linux-gnu.deb" + destination = "/tmp/defguard-core.deb" + } + + provisioner "shell" { + script = "./images/ami/core.sh" + } + + provisioner "shell" { + inline = ["rm /home/admin/.ssh/authorized_keys"] + } + + provisioner "shell" { + inline = ["sudo rm /root/.ssh/authorized_keys"] + } +} diff --git a/images/ami/core.sh b/images/ami/core.sh new file mode 100644 index 0000000000..1203c711ae --- /dev/null +++ b/images/ami/core.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -e + +echo "Updating apt repositories..." +sudo apt update + +echo "Installing Defguard package..." +sudo dpkg -i /tmp/defguard-core.deb + +echo "Cleaning up..." +sudo rm -f /tmp/defguard-core.deb + +echo "Defguard installation completed successfully." 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/crates/defguard_core/migrations/20220922092725_initial.down.sql b/migrations/20220922092725_initial.down.sql similarity index 100% rename from crates/defguard_core/migrations/20220922092725_initial.down.sql rename to migrations/20220922092725_initial.down.sql diff --git a/crates/defguard_core/migrations/20220922092725_initial.up.sql b/migrations/20220922092725_initial.up.sql similarity index 100% rename from crates/defguard_core/migrations/20220922092725_initial.up.sql rename to migrations/20220922092725_initial.up.sql diff --git a/crates/defguard_core/migrations/20221006125653_web3_mfa.down.sql b/migrations/20221006125653_web3_mfa.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221006125653_web3_mfa.down.sql rename to migrations/20221006125653_web3_mfa.down.sql diff --git a/crates/defguard_core/migrations/20221006125653_web3_mfa.up.sql b/migrations/20221006125653_web3_mfa.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221006125653_web3_mfa.up.sql rename to migrations/20221006125653_web3_mfa.up.sql diff --git a/crates/defguard_core/migrations/20221010085106_extend_mfa.down.sql b/migrations/20221010085106_extend_mfa.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221010085106_extend_mfa.down.sql rename to migrations/20221010085106_extend_mfa.down.sql diff --git a/crates/defguard_core/migrations/20221010085106_extend_mfa.up.sql b/migrations/20221010085106_extend_mfa.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221010085106_extend_mfa.up.sql rename to migrations/20221010085106_extend_mfa.up.sql diff --git a/crates/defguard_core/migrations/20221012084225_inet.down.sql b/migrations/20221012084225_inet.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221012084225_inet.down.sql rename to migrations/20221012084225_inet.down.sql diff --git a/crates/defguard_core/migrations/20221012084225_inet.up.sql b/migrations/20221012084225_inet.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221012084225_inet.up.sql rename to migrations/20221012084225_inet.up.sql diff --git a/crates/defguard_core/migrations/20221017101958_openid.down.sql b/migrations/20221017101958_openid.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221017101958_openid.down.sql rename to migrations/20221017101958_openid.down.sql diff --git a/crates/defguard_core/migrations/20221017101958_openid.up.sql b/migrations/20221017101958_openid.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221017101958_openid.up.sql rename to migrations/20221017101958_openid.up.sql diff --git a/crates/defguard_core/migrations/20221102090023_mfa.down.sql b/migrations/20221102090023_mfa.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221102090023_mfa.down.sql rename to migrations/20221102090023_mfa.down.sql diff --git a/crates/defguard_core/migrations/20221102090023_mfa.up.sql b/migrations/20221102090023_mfa.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221102090023_mfa.up.sql rename to migrations/20221102090023_mfa.up.sql diff --git a/crates/defguard_core/migrations/20221111084416_mfa_enabled.down.sql b/migrations/20221111084416_mfa_enabled.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221111084416_mfa_enabled.down.sql rename to migrations/20221111084416_mfa_enabled.down.sql diff --git a/crates/defguard_core/migrations/20221111084416_mfa_enabled.up.sql b/migrations/20221111084416_mfa_enabled.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221111084416_mfa_enabled.up.sql rename to migrations/20221111084416_mfa_enabled.up.sql diff --git a/crates/defguard_core/migrations/20221115095301_oauth.down.sql b/migrations/20221115095301_oauth.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221115095301_oauth.down.sql rename to migrations/20221115095301_oauth.down.sql diff --git a/crates/defguard_core/migrations/20221115095301_oauth.up.sql b/migrations/20221115095301_oauth.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221115095301_oauth.up.sql rename to migrations/20221115095301_oauth.up.sql diff --git a/crates/defguard_core/migrations/20221121141222_default_challenge_template.down.sql b/migrations/20221121141222_default_challenge_template.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221121141222_default_challenge_template.down.sql rename to migrations/20221121141222_default_challenge_template.down.sql diff --git a/crates/defguard_core/migrations/20221121141222_default_challenge_template.up.sql b/migrations/20221121141222_default_challenge_template.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221121141222_default_challenge_template.up.sql rename to migrations/20221121141222_default_challenge_template.up.sql diff --git a/crates/defguard_core/migrations/20221205152009_extend_oauth2token.down.sql b/migrations/20221205152009_extend_oauth2token.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221205152009_extend_oauth2token.down.sql rename to migrations/20221205152009_extend_oauth2token.down.sql diff --git a/crates/defguard_core/migrations/20221205152009_extend_oauth2token.up.sql b/migrations/20221205152009_extend_oauth2token.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221205152009_extend_oauth2token.up.sql rename to migrations/20221205152009_extend_oauth2token.up.sql diff --git a/crates/defguard_core/migrations/20221219130154_oauth2authorizedapp.down.sql b/migrations/20221219130154_oauth2authorizedapp.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221219130154_oauth2authorizedapp.down.sql rename to migrations/20221219130154_oauth2authorizedapp.down.sql diff --git a/crates/defguard_core/migrations/20221219130154_oauth2authorizedapp.up.sql b/migrations/20221219130154_oauth2authorizedapp.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221219130154_oauth2authorizedapp.up.sql rename to migrations/20221219130154_oauth2authorizedapp.up.sql diff --git a/crates/defguard_core/migrations/20221228104814_extend_settings.down.sql b/migrations/20221228104814_extend_settings.down.sql similarity index 100% rename from crates/defguard_core/migrations/20221228104814_extend_settings.down.sql rename to migrations/20221228104814_extend_settings.down.sql diff --git a/crates/defguard_core/migrations/20221228104814_extend_settings.up.sql b/migrations/20221228104814_extend_settings.up.sql similarity index 100% rename from crates/defguard_core/migrations/20221228104814_extend_settings.up.sql rename to migrations/20221228104814_extend_settings.up.sql diff --git a/crates/defguard_core/migrations/20230614093458_wireguard_network_device.down.sql b/migrations/20230614093458_wireguard_network_device.down.sql similarity index 100% rename from crates/defguard_core/migrations/20230614093458_wireguard_network_device.down.sql rename to migrations/20230614093458_wireguard_network_device.down.sql diff --git a/crates/defguard_core/migrations/20230614093458_wireguard_network_device.up.sql b/migrations/20230614093458_wireguard_network_device.up.sql similarity index 100% rename from crates/defguard_core/migrations/20230614093458_wireguard_network_device.up.sql rename to migrations/20230614093458_wireguard_network_device.up.sql diff --git a/crates/defguard_core/migrations/20230705075759_network_allowed_groups.down.sql b/migrations/20230705075759_network_allowed_groups.down.sql similarity index 100% rename from crates/defguard_core/migrations/20230705075759_network_allowed_groups.down.sql rename to migrations/20230705075759_network_allowed_groups.down.sql diff --git a/crates/defguard_core/migrations/20230705075759_network_allowed_groups.up.sql b/migrations/20230705075759_network_allowed_groups.up.sql similarity index 100% rename from crates/defguard_core/migrations/20230705075759_network_allowed_groups.up.sql rename to migrations/20230705075759_network_allowed_groups.up.sql diff --git a/crates/defguard_core/migrations/20230706101427_network_ip_type.down.sql b/migrations/20230706101427_network_ip_type.down.sql similarity index 100% rename from crates/defguard_core/migrations/20230706101427_network_ip_type.down.sql rename to migrations/20230706101427_network_ip_type.down.sql diff --git a/crates/defguard_core/migrations/20230706101427_network_ip_type.up.sql b/migrations/20230706101427_network_ip_type.up.sql similarity index 100% rename from crates/defguard_core/migrations/20230706101427_network_ip_type.up.sql rename to migrations/20230706101427_network_ip_type.up.sql diff --git a/crates/defguard_core/migrations/20230714115826_settings.down.sql b/migrations/20230714115826_settings.down.sql similarity index 100% rename from crates/defguard_core/migrations/20230714115826_settings.down.sql rename to migrations/20230714115826_settings.down.sql diff --git a/crates/defguard_core/migrations/20230714115826_settings.up.sql b/migrations/20230714115826_settings.up.sql similarity index 100% rename from crates/defguard_core/migrations/20230714115826_settings.up.sql rename to migrations/20230714115826_settings.up.sql diff --git a/crates/defguard_core/migrations/20230725115257_wireguard_stats_purge.down.sql b/migrations/20230725115257_wireguard_stats_purge.down.sql similarity index 100% rename from crates/defguard_core/migrations/20230725115257_wireguard_stats_purge.down.sql rename to migrations/20230725115257_wireguard_stats_purge.down.sql diff --git a/crates/defguard_core/migrations/20230725115257_wireguard_stats_purge.up.sql b/migrations/20230725115257_wireguard_stats_purge.up.sql similarity index 100% rename from crates/defguard_core/migrations/20230725115257_wireguard_stats_purge.up.sql rename to migrations/20230725115257_wireguard_stats_purge.up.sql diff --git a/crates/defguard_core/migrations/20230726071856_smtp.down.sql b/migrations/20230726071856_smtp.down.sql similarity index 100% rename from crates/defguard_core/migrations/20230726071856_smtp.down.sql rename to migrations/20230726071856_smtp.down.sql diff --git a/crates/defguard_core/migrations/20230726071856_smtp.up.sql b/migrations/20230726071856_smtp.up.sql similarity index 100% rename from crates/defguard_core/migrations/20230726071856_smtp.up.sql rename to migrations/20230726071856_smtp.up.sql diff --git a/crates/defguard_core/migrations/20230728091355_enrollment.down.sql b/migrations/20230728091355_enrollment.down.sql similarity index 100% rename from crates/defguard_core/migrations/20230728091355_enrollment.down.sql rename to migrations/20230728091355_enrollment.down.sql diff --git a/crates/defguard_core/migrations/20230728091355_enrollment.up.sql b/migrations/20230728091355_enrollment.up.sql similarity index 100% rename from crates/defguard_core/migrations/20230728091355_enrollment.up.sql rename to migrations/20230728091355_enrollment.up.sql diff --git a/crates/defguard_core/migrations/20230918074044_settings_uuid.down.sql b/migrations/20230918074044_settings_uuid.down.sql similarity index 100% rename from crates/defguard_core/migrations/20230918074044_settings_uuid.down.sql rename to migrations/20230918074044_settings_uuid.down.sql diff --git a/crates/defguard_core/migrations/20230918074044_settings_uuid.up.sql b/migrations/20230918074044_settings_uuid.up.sql similarity index 100% rename from crates/defguard_core/migrations/20230918074044_settings_uuid.up.sql rename to migrations/20230918074044_settings_uuid.up.sql diff --git a/crates/defguard_core/migrations/20231114112643_email_mfa.down.sql b/migrations/20231114112643_email_mfa.down.sql similarity index 100% rename from crates/defguard_core/migrations/20231114112643_email_mfa.down.sql rename to migrations/20231114112643_email_mfa.down.sql diff --git a/crates/defguard_core/migrations/20231114112643_email_mfa.up.sql b/migrations/20231114112643_email_mfa.up.sql similarity index 100% rename from crates/defguard_core/migrations/20231114112643_email_mfa.up.sql rename to migrations/20231114112643_email_mfa.up.sql diff --git a/crates/defguard_core/migrations/20231114120444_settings_ldap.down.sql b/migrations/20231114120444_settings_ldap.down.sql similarity index 100% rename from crates/defguard_core/migrations/20231114120444_settings_ldap.down.sql rename to migrations/20231114120444_settings_ldap.down.sql diff --git a/crates/defguard_core/migrations/20231114120444_settings_ldap.up.sql b/migrations/20231114120444_settings_ldap.up.sql similarity index 100% rename from crates/defguard_core/migrations/20231114120444_settings_ldap.up.sql rename to migrations/20231114120444_settings_ldap.up.sql diff --git a/crates/defguard_core/migrations/20231114193933_device_login_event.down.sql b/migrations/20231114193933_device_login_event.down.sql similarity index 100% rename from crates/defguard_core/migrations/20231114193933_device_login_event.down.sql rename to migrations/20231114193933_device_login_event.down.sql diff --git a/crates/defguard_core/migrations/20231114193933_device_login_event.up.sql b/migrations/20231114193933_device_login_event.up.sql similarity index 100% rename from crates/defguard_core/migrations/20231114193933_device_login_event.up.sql rename to migrations/20231114193933_device_login_event.up.sql diff --git a/crates/defguard_core/migrations/20231117100511_session_ip_address_device_info.down.sql b/migrations/20231117100511_session_ip_address_device_info.down.sql similarity index 100% rename from crates/defguard_core/migrations/20231117100511_session_ip_address_device_info.down.sql rename to migrations/20231117100511_session_ip_address_device_info.down.sql diff --git a/crates/defguard_core/migrations/20231117100511_session_ip_address_device_info.up.sql b/migrations/20231117100511_session_ip_address_device_info.up.sql similarity index 100% rename from crates/defguard_core/migrations/20231117100511_session_ip_address_device_info.up.sql rename to migrations/20231117100511_session_ip_address_device_info.up.sql diff --git a/crates/defguard_core/migrations/20231207160414_enrollment_table_generic.down.sql b/migrations/20231207160414_enrollment_table_generic.down.sql similarity index 100% rename from crates/defguard_core/migrations/20231207160414_enrollment_table_generic.down.sql rename to migrations/20231207160414_enrollment_table_generic.down.sql diff --git a/crates/defguard_core/migrations/20231207160414_enrollment_table_generic.up.sql b/migrations/20231207160414_enrollment_table_generic.up.sql similarity index 100% rename from crates/defguard_core/migrations/20231207160414_enrollment_table_generic.up.sql rename to migrations/20231207160414_enrollment_table_generic.up.sql diff --git a/crates/defguard_core/migrations/20231220103051_add_preshared_key.down.sql b/migrations/20231220103051_add_preshared_key.down.sql similarity index 100% rename from crates/defguard_core/migrations/20231220103051_add_preshared_key.down.sql rename to migrations/20231220103051_add_preshared_key.down.sql diff --git a/crates/defguard_core/migrations/20231220103051_add_preshared_key.up.sql b/migrations/20231220103051_add_preshared_key.up.sql similarity index 100% rename from crates/defguard_core/migrations/20231220103051_add_preshared_key.up.sql rename to migrations/20231220103051_add_preshared_key.up.sql diff --git a/crates/defguard_core/migrations/20231220112404_update_location_settings.down.sql b/migrations/20231220112404_update_location_settings.down.sql similarity index 100% rename from crates/defguard_core/migrations/20231220112404_update_location_settings.down.sql rename to migrations/20231220112404_update_location_settings.down.sql diff --git a/crates/defguard_core/migrations/20231220112404_update_location_settings.up.sql b/migrations/20231220112404_update_location_settings.up.sql similarity index 100% rename from crates/defguard_core/migrations/20231220112404_update_location_settings.up.sql rename to migrations/20231220112404_update_location_settings.up.sql diff --git a/crates/defguard_core/migrations/20231222094917_support_gateway_disconnect.down.sql b/migrations/20231222094917_support_gateway_disconnect.down.sql similarity index 100% rename from crates/defguard_core/migrations/20231222094917_support_gateway_disconnect.down.sql rename to migrations/20231222094917_support_gateway_disconnect.down.sql diff --git a/crates/defguard_core/migrations/20231222094917_support_gateway_disconnect.up.sql b/migrations/20231222094917_support_gateway_disconnect.up.sql similarity index 100% rename from crates/defguard_core/migrations/20231222094917_support_gateway_disconnect.up.sql rename to migrations/20231222094917_support_gateway_disconnect.up.sql diff --git a/crates/defguard_core/migrations/20231227091628_fix_preshared_key.down.sql b/migrations/20231227091628_fix_preshared_key.down.sql similarity index 100% rename from crates/defguard_core/migrations/20231227091628_fix_preshared_key.down.sql rename to migrations/20231227091628_fix_preshared_key.down.sql diff --git a/crates/defguard_core/migrations/20231227091628_fix_preshared_key.up.sql b/migrations/20231227091628_fix_preshared_key.up.sql similarity index 100% rename from crates/defguard_core/migrations/20231227091628_fix_preshared_key.up.sql rename to migrations/20231227091628_fix_preshared_key.up.sql diff --git a/crates/defguard_core/migrations/20240124063948_adjust_peer_disconnect.down.sql b/migrations/20240124063948_adjust_peer_disconnect.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240124063948_adjust_peer_disconnect.down.sql rename to migrations/20240124063948_adjust_peer_disconnect.down.sql diff --git a/crates/defguard_core/migrations/20240124063948_adjust_peer_disconnect.up.sql b/migrations/20240124063948_adjust_peer_disconnect.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240124063948_adjust_peer_disconnect.up.sql rename to migrations/20240124063948_adjust_peer_disconnect.up.sql diff --git a/crates/defguard_core/migrations/20240214090709_add_mfa_authorization_timestamp.down.sql b/migrations/20240214090709_add_mfa_authorization_timestamp.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240214090709_add_mfa_authorization_timestamp.down.sql rename to migrations/20240214090709_add_mfa_authorization_timestamp.down.sql diff --git a/crates/defguard_core/migrations/20240214090709_add_mfa_authorization_timestamp.up.sql b/migrations/20240214090709_add_mfa_authorization_timestamp.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240214090709_add_mfa_authorization_timestamp.up.sql rename to migrations/20240214090709_add_mfa_authorization_timestamp.up.sql diff --git a/crates/defguard_core/migrations/20240216195802_authentication_key.down.sql b/migrations/20240216195802_authentication_key.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240216195802_authentication_key.down.sql rename to migrations/20240216195802_authentication_key.down.sql diff --git a/crates/defguard_core/migrations/20240216195802_authentication_key.up.sql b/migrations/20240216195802_authentication_key.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240216195802_authentication_key.up.sql rename to migrations/20240216195802_authentication_key.up.sql diff --git a/crates/defguard_core/migrations/20240318113507_auth_key_constraint.down.sql b/migrations/20240318113507_auth_key_constraint.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240318113507_auth_key_constraint.down.sql rename to migrations/20240318113507_auth_key_constraint.down.sql diff --git a/crates/defguard_core/migrations/20240318113507_auth_key_constraint.up.sql b/migrations/20240318113507_auth_key_constraint.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240318113507_auth_key_constraint.up.sql rename to migrations/20240318113507_auth_key_constraint.up.sql diff --git a/crates/defguard_core/migrations/20240604104038_add_user_active_flag.down.sql b/migrations/20240604104038_add_user_active_flag.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240604104038_add_user_active_flag.down.sql rename to migrations/20240604104038_add_user_active_flag.down.sql diff --git a/crates/defguard_core/migrations/20240604104038_add_user_active_flag.up.sql b/migrations/20240604104038_add_user_active_flag.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240604104038_add_user_active_flag.up.sql rename to migrations/20240604104038_add_user_active_flag.up.sql diff --git a/crates/defguard_core/migrations/20240716114732_add_external_openid_login.down.sql b/migrations/20240716114732_add_external_openid_login.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240716114732_add_external_openid_login.down.sql rename to migrations/20240716114732_add_external_openid_login.down.sql diff --git a/crates/defguard_core/migrations/20240716114732_add_external_openid_login.up.sql b/migrations/20240716114732_add_external_openid_login.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240716114732_add_external_openid_login.up.sql rename to migrations/20240716114732_add_external_openid_login.up.sql diff --git a/crates/defguard_core/migrations/20240809083720_add_licenses.down.sql b/migrations/20240809083720_add_licenses.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240809083720_add_licenses.down.sql rename to migrations/20240809083720_add_licenses.down.sql diff --git a/crates/defguard_core/migrations/20240809083720_add_licenses.up.sql b/migrations/20240809083720_add_licenses.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240809083720_add_licenses.up.sql rename to migrations/20240809083720_add_licenses.up.sql diff --git a/crates/defguard_core/migrations/20240819134151_enterprise_settings.down.sql b/migrations/20240819134151_enterprise_settings.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240819134151_enterprise_settings.down.sql rename to migrations/20240819134151_enterprise_settings.down.sql diff --git a/crates/defguard_core/migrations/20240819134151_enterprise_settings.up.sql b/migrations/20240819134151_enterprise_settings.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240819134151_enterprise_settings.up.sql rename to migrations/20240819134151_enterprise_settings.up.sql diff --git a/crates/defguard_core/migrations/20240830094910_wireguard_manual_setup_setting.down.sql b/migrations/20240830094910_wireguard_manual_setup_setting.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240830094910_wireguard_manual_setup_setting.down.sql rename to migrations/20240830094910_wireguard_manual_setup_setting.down.sql diff --git a/crates/defguard_core/migrations/20240830094910_wireguard_manual_setup_setting.up.sql b/migrations/20240830094910_wireguard_manual_setup_setting.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240830094910_wireguard_manual_setup_setting.up.sql rename to migrations/20240830094910_wireguard_manual_setup_setting.up.sql diff --git a/crates/defguard_core/migrations/20240902103930_add_option_to_disable_all_traffic.down.sql b/migrations/20240902103930_add_option_to_disable_all_traffic.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240902103930_add_option_to_disable_all_traffic.down.sql rename to migrations/20240902103930_add_option_to_disable_all_traffic.down.sql diff --git a/crates/defguard_core/migrations/20240902103930_add_option_to_disable_all_traffic.up.sql b/migrations/20240902103930_add_option_to_disable_all_traffic.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240902103930_add_option_to_disable_all_traffic.up.sql rename to migrations/20240902103930_add_option_to_disable_all_traffic.up.sql diff --git a/crates/defguard_core/migrations/20240906090729_polling_token.down.sql b/migrations/20240906090729_polling_token.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240906090729_polling_token.down.sql rename to migrations/20240906090729_polling_token.down.sql diff --git a/crates/defguard_core/migrations/20240906090729_polling_token.up.sql b/migrations/20240906090729_polling_token.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240906090729_polling_token.up.sql rename to migrations/20240906090729_polling_token.up.sql diff --git a/crates/defguard_core/migrations/20240909143350_add_openid_sub.down.sql b/migrations/20240909143350_add_openid_sub.down.sql similarity index 100% rename from crates/defguard_core/migrations/20240909143350_add_openid_sub.down.sql rename to migrations/20240909143350_add_openid_sub.down.sql diff --git a/crates/defguard_core/migrations/20240909143350_add_openid_sub.up.sql b/migrations/20240909143350_add_openid_sub.up.sql similarity index 100% rename from crates/defguard_core/migrations/20240909143350_add_openid_sub.up.sql rename to migrations/20240909143350_add_openid_sub.up.sql diff --git a/crates/defguard_core/migrations/20241108110157_add_on_delete.down.sql b/migrations/20241108110157_add_on_delete.down.sql similarity index 100% rename from crates/defguard_core/migrations/20241108110157_add_on_delete.down.sql rename to migrations/20241108110157_add_on_delete.down.sql diff --git a/crates/defguard_core/migrations/20241108110157_add_on_delete.up.sql b/migrations/20241108110157_add_on_delete.up.sql similarity index 100% rename from crates/defguard_core/migrations/20241108110157_add_on_delete.up.sql rename to migrations/20241108110157_add_on_delete.up.sql diff --git a/crates/defguard_core/migrations/20241112105513_openid_sub_unique.down.sql b/migrations/20241112105513_openid_sub_unique.down.sql similarity index 100% rename from crates/defguard_core/migrations/20241112105513_openid_sub_unique.down.sql rename to migrations/20241112105513_openid_sub_unique.down.sql diff --git a/crates/defguard_core/migrations/20241112105513_openid_sub_unique.up.sql b/migrations/20241112105513_openid_sub_unique.up.sql similarity index 100% rename from crates/defguard_core/migrations/20241112105513_openid_sub_unique.up.sql rename to migrations/20241112105513_openid_sub_unique.up.sql diff --git a/crates/defguard_core/migrations/20241119105926_disable_wallet_mfa.down.sql b/migrations/20241119105926_disable_wallet_mfa.down.sql similarity index 100% rename from crates/defguard_core/migrations/20241119105926_disable_wallet_mfa.down.sql rename to migrations/20241119105926_disable_wallet_mfa.down.sql diff --git a/crates/defguard_core/migrations/20241119105926_disable_wallet_mfa.up.sql b/migrations/20241119105926_disable_wallet_mfa.up.sql similarity index 100% rename from crates/defguard_core/migrations/20241119105926_disable_wallet_mfa.up.sql rename to migrations/20241119105926_disable_wallet_mfa.up.sql diff --git a/crates/defguard_core/migrations/20241129095100_openid_directory_sync.down.sql b/migrations/20241129095100_openid_directory_sync.down.sql similarity index 100% rename from crates/defguard_core/migrations/20241129095100_openid_directory_sync.down.sql rename to migrations/20241129095100_openid_directory_sync.down.sql diff --git a/crates/defguard_core/migrations/20241129095100_openid_directory_sync.up.sql b/migrations/20241129095100_openid_directory_sync.up.sql similarity index 100% rename from crates/defguard_core/migrations/20241129095100_openid_directory_sync.up.sql rename to migrations/20241129095100_openid_directory_sync.up.sql diff --git a/crates/defguard_core/migrations/20241204113104_case_insensitive_email.down.sql b/migrations/20241204113104_case_insensitive_email.down.sql similarity index 100% rename from crates/defguard_core/migrations/20241204113104_case_insensitive_email.down.sql rename to migrations/20241204113104_case_insensitive_email.down.sql diff --git a/crates/defguard_core/migrations/20241204113104_case_insensitive_email.up.sql b/migrations/20241204113104_case_insensitive_email.up.sql similarity index 100% rename from crates/defguard_core/migrations/20241204113104_case_insensitive_email.up.sql rename to migrations/20241204113104_case_insensitive_email.up.sql diff --git a/crates/defguard_core/migrations/20241211115639_directory_sync_target.down.sql b/migrations/20241211115639_directory_sync_target.down.sql similarity index 100% rename from crates/defguard_core/migrations/20241211115639_directory_sync_target.down.sql rename to migrations/20241211115639_directory_sync_target.down.sql diff --git a/crates/defguard_core/migrations/20241211115639_directory_sync_target.up.sql b/migrations/20241211115639_directory_sync_target.up.sql similarity index 100% rename from crates/defguard_core/migrations/20241211115639_directory_sync_target.up.sql rename to migrations/20241211115639_directory_sync_target.up.sql diff --git a/crates/defguard_core/migrations/20241212091902_add_admin_permission.down.sql b/migrations/20241212091902_add_admin_permission.down.sql similarity index 100% rename from crates/defguard_core/migrations/20241212091902_add_admin_permission.down.sql rename to migrations/20241212091902_add_admin_permission.down.sql diff --git a/crates/defguard_core/migrations/20241212091902_add_admin_permission.up.sql b/migrations/20241212091902_add_admin_permission.up.sql similarity index 100% rename from crates/defguard_core/migrations/20241212091902_add_admin_permission.up.sql rename to migrations/20241212091902_add_admin_permission.up.sql diff --git a/crates/defguard_core/migrations/20250107125423_network_devices_fixes_and_cleanup.down.sql b/migrations/20250107125423_network_devices_fixes_and_cleanup.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250107125423_network_devices_fixes_and_cleanup.down.sql rename to migrations/20250107125423_network_devices_fixes_and_cleanup.down.sql diff --git a/crates/defguard_core/migrations/20250107125423_network_devices_fixes_and_cleanup.up.sql b/migrations/20250107125423_network_devices_fixes_and_cleanup.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250107125423_network_devices_fixes_and_cleanup.up.sql rename to migrations/20250107125423_network_devices_fixes_and_cleanup.up.sql diff --git a/crates/defguard_core/migrations/20250108092749_gateway_disconnect_notifications_settings.down.sql b/migrations/20250108092749_gateway_disconnect_notifications_settings.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250108092749_gateway_disconnect_notifications_settings.down.sql rename to migrations/20250108092749_gateway_disconnect_notifications_settings.down.sql diff --git a/crates/defguard_core/migrations/20250108092749_gateway_disconnect_notifications_settings.up.sql b/migrations/20250108092749_gateway_disconnect_notifications_settings.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250108092749_gateway_disconnect_notifications_settings.up.sql rename to migrations/20250108092749_gateway_disconnect_notifications_settings.up.sql diff --git a/crates/defguard_core/migrations/20250120115015_network_device_primary_key.down.sql b/migrations/20250120115015_network_device_primary_key.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250120115015_network_device_primary_key.down.sql rename to migrations/20250120115015_network_device_primary_key.down.sql diff --git a/crates/defguard_core/migrations/20250120115015_network_device_primary_key.up.sql b/migrations/20250120115015_network_device_primary_key.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250120115015_network_device_primary_key.up.sql rename to migrations/20250120115015_network_device_primary_key.up.sql diff --git a/crates/defguard_core/migrations/20250129114956_okta_dirsync.down.sql b/migrations/20250129114956_okta_dirsync.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250129114956_okta_dirsync.down.sql rename to migrations/20250129114956_okta_dirsync.down.sql diff --git a/crates/defguard_core/migrations/20250129114956_okta_dirsync.up.sql b/migrations/20250129114956_okta_dirsync.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250129114956_okta_dirsync.up.sql rename to migrations/20250129114956_okta_dirsync.up.sql diff --git a/crates/defguard_core/migrations/20250206111732_add_api_tokens.down.sql b/migrations/20250206111732_add_api_tokens.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250206111732_add_api_tokens.down.sql rename to migrations/20250206111732_add_api_tokens.down.sql diff --git a/crates/defguard_core/migrations/20250206111732_add_api_tokens.up.sql b/migrations/20250206111732_add_api_tokens.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250206111732_add_api_tokens.up.sql rename to migrations/20250206111732_add_api_tokens.up.sql diff --git a/crates/defguard_core/migrations/20250213094023_acl.down.sql b/migrations/20250213094023_acl.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250213094023_acl.down.sql rename to migrations/20250213094023_acl.down.sql diff --git a/crates/defguard_core/migrations/20250213094023_acl.up.sql b/migrations/20250213094023_acl.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250213094023_acl.up.sql rename to migrations/20250213094023_acl.up.sql diff --git a/crates/defguard_core/migrations/20250228101233_directory_sync_group_match.down.sql b/migrations/20250228101233_directory_sync_group_match.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250228101233_directory_sync_group_match.down.sql rename to migrations/20250228101233_directory_sync_group_match.down.sql diff --git a/crates/defguard_core/migrations/20250228101233_directory_sync_group_match.up.sql b/migrations/20250228101233_directory_sync_group_match.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250228101233_directory_sync_group_match.up.sql rename to migrations/20250228101233_directory_sync_group_match.up.sql diff --git a/crates/defguard_core/migrations/20250304071532_acl_location.down.sql b/migrations/20250304071532_acl_location.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250304071532_acl_location.down.sql rename to migrations/20250304071532_acl_location.down.sql diff --git a/crates/defguard_core/migrations/20250304071532_acl_location.up.sql b/migrations/20250304071532_acl_location.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250304071532_acl_location.up.sql rename to migrations/20250304071532_acl_location.up.sql diff --git a/crates/defguard_core/migrations/20250306122343_ldap_two_way_sync.down.sql b/migrations/20250306122343_ldap_two_way_sync.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250306122343_ldap_two_way_sync.down.sql rename to migrations/20250306122343_ldap_two_way_sync.down.sql diff --git a/crates/defguard_core/migrations/20250306122343_ldap_two_way_sync.up.sql b/migrations/20250306122343_ldap_two_way_sync.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250306122343_ldap_two_way_sync.up.sql rename to migrations/20250306122343_ldap_two_way_sync.up.sql diff --git a/crates/defguard_core/migrations/20250310065848_acl_rulestate.down.sql b/migrations/20250310065848_acl_rulestate.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250310065848_acl_rulestate.down.sql rename to migrations/20250310065848_acl_rulestate.down.sql diff --git a/crates/defguard_core/migrations/20250310065848_acl_rulestate.up.sql b/migrations/20250310065848_acl_rulestate.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250310065848_acl_rulestate.up.sql rename to migrations/20250310065848_acl_rulestate.up.sql diff --git a/crates/defguard_core/migrations/20250312201929_ldap_settings.down.sql b/migrations/20250312201929_ldap_settings.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250312201929_ldap_settings.down.sql rename to migrations/20250312201929_ldap_settings.down.sql diff --git a/crates/defguard_core/migrations/20250312201929_ldap_settings.up.sql b/migrations/20250312201929_ldap_settings.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250312201929_ldap_settings.up.sql rename to migrations/20250312201929_ldap_settings.up.sql diff --git a/crates/defguard_core/migrations/20250320104446_acl_allow_conflicting_sources.down.sql b/migrations/20250320104446_acl_allow_conflicting_sources.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250320104446_acl_allow_conflicting_sources.down.sql rename to migrations/20250320104446_acl_allow_conflicting_sources.down.sql diff --git a/crates/defguard_core/migrations/20250320104446_acl_allow_conflicting_sources.up.sql b/migrations/20250320104446_acl_allow_conflicting_sources.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250320104446_acl_allow_conflicting_sources.up.sql rename to migrations/20250320104446_acl_allow_conflicting_sources.up.sql diff --git a/crates/defguard_core/migrations/20250321110001_add_allow_deny_all_network_devices.down.sql b/migrations/20250321110001_add_allow_deny_all_network_devices.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250321110001_add_allow_deny_all_network_devices.down.sql rename to migrations/20250321110001_add_allow_deny_all_network_devices.down.sql diff --git a/crates/defguard_core/migrations/20250321110001_add_allow_deny_all_network_devices.up.sql b/migrations/20250321110001_add_allow_deny_all_network_devices.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250321110001_add_allow_deny_all_network_devices.up.sql rename to migrations/20250321110001_add_allow_deny_all_network_devices.up.sql diff --git a/crates/defguard_core/migrations/20250401134629_add_acl_alias_state.down.sql b/migrations/20250401134629_add_acl_alias_state.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250401134629_add_acl_alias_state.down.sql rename to migrations/20250401134629_add_acl_alias_state.down.sql diff --git a/crates/defguard_core/migrations/20250401134629_add_acl_alias_state.up.sql b/migrations/20250401134629_add_acl_alias_state.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250401134629_add_acl_alias_state.up.sql rename to migrations/20250401134629_add_acl_alias_state.up.sql diff --git a/crates/defguard_core/migrations/20250407115800_add_acl_rule_expired_state.down.sql b/migrations/20250407115800_add_acl_rule_expired_state.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250407115800_add_acl_rule_expired_state.down.sql rename to migrations/20250407115800_add_acl_rule_expired_state.down.sql diff --git a/crates/defguard_core/migrations/20250407115800_add_acl_rule_expired_state.up.sql b/migrations/20250407115800_add_acl_rule_expired_state.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250407115800_add_acl_rule_expired_state.up.sql rename to migrations/20250407115800_add_acl_rule_expired_state.up.sql diff --git a/crates/defguard_core/migrations/20250410075857_ldap_username_mapping.down.sql b/migrations/20250410075857_ldap_username_mapping.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250410075857_ldap_username_mapping.down.sql rename to migrations/20250410075857_ldap_username_mapping.down.sql diff --git a/crates/defguard_core/migrations/20250410075857_ldap_username_mapping.up.sql b/migrations/20250410075857_ldap_username_mapping.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250410075857_ldap_username_mapping.up.sql rename to migrations/20250410075857_ldap_username_mapping.up.sql diff --git a/crates/defguard_core/migrations/20250422091755_ldap_sync_groups.down.sql b/migrations/20250422091755_ldap_sync_groups.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250422091755_ldap_sync_groups.down.sql rename to migrations/20250422091755_ldap_sync_groups.down.sql diff --git a/crates/defguard_core/migrations/20250422091755_ldap_sync_groups.up.sql b/migrations/20250422091755_ldap_sync_groups.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250422091755_ldap_sync_groups.up.sql rename to migrations/20250422091755_ldap_sync_groups.up.sql diff --git a/crates/defguard_core/migrations/20250425071954_add_acl_alias_kind.down.sql b/migrations/20250425071954_add_acl_alias_kind.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250425071954_add_acl_alias_kind.down.sql rename to migrations/20250425071954_add_acl_alias_kind.down.sql diff --git a/crates/defguard_core/migrations/20250425071954_add_acl_alias_kind.up.sql b/migrations/20250425071954_add_acl_alias_kind.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250425071954_add_acl_alias_kind.up.sql rename to migrations/20250425071954_add_acl_alias_kind.up.sql diff --git a/crates/defguard_core/migrations/20250505093148_allow_replacing_forbidden_characters.down.sql b/migrations/20250505093148_allow_replacing_forbidden_characters.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250505093148_allow_replacing_forbidden_characters.down.sql rename to migrations/20250505093148_allow_replacing_forbidden_characters.down.sql diff --git a/crates/defguard_core/migrations/20250505093148_allow_replacing_forbidden_characters.up.sql b/migrations/20250505093148_allow_replacing_forbidden_characters.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250505093148_allow_replacing_forbidden_characters.up.sql rename to migrations/20250505093148_allow_replacing_forbidden_characters.up.sql diff --git a/crates/defguard_core/migrations/20250509095404_fix_wireguard_network_stats_view.down.sql b/migrations/20250509095404_fix_wireguard_network_stats_view.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250509095404_fix_wireguard_network_stats_view.down.sql rename to migrations/20250509095404_fix_wireguard_network_stats_view.down.sql diff --git a/crates/defguard_core/migrations/20250509095404_fix_wireguard_network_stats_view.up.sql b/migrations/20250509095404_fix_wireguard_network_stats_view.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250509095404_fix_wireguard_network_stats_view.up.sql rename to migrations/20250509095404_fix_wireguard_network_stats_view.up.sql diff --git a/crates/defguard_core/migrations/20250514000000_multiple_peer_addresses.down.sql b/migrations/20250514000000_multiple_peer_addresses.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250514000000_multiple_peer_addresses.down.sql rename to migrations/20250514000000_multiple_peer_addresses.down.sql diff --git a/crates/defguard_core/migrations/20250514000000_multiple_peer_addresses.up.sql b/migrations/20250514000000_multiple_peer_addresses.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250514000000_multiple_peer_addresses.up.sql rename to migrations/20250514000000_multiple_peer_addresses.up.sql diff --git a/crates/defguard_core/migrations/20250515170938_add_activity_log.down.sql b/migrations/20250515170938_add_activity_log.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250515170938_add_activity_log.down.sql rename to migrations/20250515170938_add_activity_log.down.sql diff --git a/crates/defguard_core/migrations/20250515170938_add_activity_log.up.sql b/migrations/20250515170938_add_activity_log.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250515170938_add_activity_log.up.sql rename to migrations/20250515170938_add_activity_log.up.sql diff --git a/crates/defguard_core/migrations/20250526072652_activity_log_stream.down.sql b/migrations/20250526072652_activity_log_stream.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250526072652_activity_log_stream.down.sql rename to migrations/20250526072652_activity_log_stream.down.sql diff --git a/crates/defguard_core/migrations/20250526072652_activity_log_stream.up.sql b/migrations/20250526072652_activity_log_stream.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250526072652_activity_log_stream.up.sql rename to migrations/20250526072652_activity_log_stream.up.sql diff --git a/migrations/20250612111316_client_oidc_2fa.down.sql b/migrations/20250612111316_client_oidc_2fa.down.sql new file mode 100644 index 0000000000..12c99cb1dd --- /dev/null +++ b/migrations/20250612111316_client_oidc_2fa.down.sql @@ -0,0 +1 @@ +ALTER TABLE settings DROP COLUMN use_openid_for_mfa; diff --git a/migrations/20250612111316_client_oidc_2fa.up.sql b/migrations/20250612111316_client_oidc_2fa.up.sql new file mode 100644 index 0000000000..e937b090f3 --- /dev/null +++ b/migrations/20250612111316_client_oidc_2fa.up.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN use_openid_for_mfa BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/defguard_core/migrations/20250613093430_ldap_user_path.down.sql b/migrations/20250613093430_ldap_user_path.down.sql similarity index 100% rename from crates/defguard_core/migrations/20250613093430_ldap_user_path.down.sql rename to migrations/20250613093430_ldap_user_path.down.sql diff --git a/crates/defguard_core/migrations/20250613093430_ldap_user_path.up.sql b/migrations/20250613093430_ldap_user_path.up.sql similarity index 100% rename from crates/defguard_core/migrations/20250613093430_ldap_user_path.up.sql rename to migrations/20250613093430_ldap_user_path.up.sql diff --git a/migrations/20250616071627_add_user_snat.down.sql b/migrations/20250616071627_add_user_snat.down.sql new file mode 100644 index 0000000000..c98ffc6336 --- /dev/null +++ b/migrations/20250616071627_add_user_snat.down.sql @@ -0,0 +1 @@ +DROP TABLE user_snat_binding; diff --git a/migrations/20250616071627_add_user_snat.up.sql b/migrations/20250616071627_add_user_snat.up.sql new file mode 100644 index 0000000000..8e0974e7d7 --- /dev/null +++ b/migrations/20250616071627_add_user_snat.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE user_snat_binding ( + id bigserial PRIMARY KEY, + user_id bigint NOT NULL, + location_id bigint NOT NULL, + public_ip inet NOT NULL, + FOREIGN KEY(user_id) REFERENCES "user"(id) ON DELETE CASCADE, + FOREIGN KEY(location_id) REFERENCES "wireguard_network"(id) ON DELETE CASCADE, + CONSTRAINT user_location UNIQUE (user_id, location_id) +); diff --git a/migrations/20250627111713_add_activity_log_description.down.sql b/migrations/20250627111713_add_activity_log_description.down.sql new file mode 100644 index 0000000000..4cc2d283b7 --- /dev/null +++ b/migrations/20250627111713_add_activity_log_description.down.sql @@ -0,0 +1 @@ +ALTER TABLE activity_log_event DROP COLUMN "description"; diff --git a/migrations/20250627111713_add_activity_log_description.up.sql b/migrations/20250627111713_add_activity_log_description.up.sql new file mode 100644 index 0000000000..8095217ee3 --- /dev/null +++ b/migrations/20250627111713_add_activity_log_description.up.sql @@ -0,0 +1 @@ +ALTER TABLE activity_log_event ADD COLUMN "description" TEXT; diff --git a/migrations/20250711063346_add_activity_log_event_location.down.sql b/migrations/20250711063346_add_activity_log_event_location.down.sql new file mode 100644 index 0000000000..16a57cb3a0 --- /dev/null +++ b/migrations/20250711063346_add_activity_log_event_location.down.sql @@ -0,0 +1 @@ +ALTER TABLE activity_log_event DROP COLUMN "location"; diff --git a/migrations/20250711063346_add_activity_log_event_location.up.sql b/migrations/20250711063346_add_activity_log_event_location.up.sql new file mode 100644 index 0000000000..15d1bba8cb --- /dev/null +++ b/migrations/20250711063346_add_activity_log_event_location.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE activity_log_event ADD COLUMN "location" TEXT; + +CREATE INDEX activity_log_event_location_idx ON activity_log_event(location); diff --git a/migrations/20250714203243_add_location_mfa_settings.down.sql b/migrations/20250714203243_add_location_mfa_settings.down.sql new file mode 100644 index 0000000000..f869736c67 --- /dev/null +++ b/migrations/20250714203243_add_location_mfa_settings.down.sql @@ -0,0 +1,19 @@ +-- restore boolean `mfa_enabled` column +ALTER TABLE wireguard_network ADD COLUMN "mfa_enabled" BOOLEAN DEFAULT false; + +-- populate based on MFA type +UPDATE wireguard_network +SET mfa_enabled = CASE + WHEN location_mfa_mode = 'disabled'::location_mfa_mode THEN false + ELSE true +END; +-- +-- make restored column NOT NULL +ALTER TABLE wireguard_network ALTER COLUMN "mfa_enabled" SET NOT NULL; + +-- drop new column and type +ALTER TABLE wireguard_network DROP COLUMN "location_mfa_mode"; +DROP TYPE location_mfa_mode; + +-- restore `use_openid_for_mfa` setting +ALTER TABLE settings ADD COLUMN use_openid_for_mfa BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/20250714203243_add_location_mfa_settings.up.sql b/migrations/20250714203243_add_location_mfa_settings.up.sql new file mode 100644 index 0000000000..28dda93113 --- /dev/null +++ b/migrations/20250714203243_add_location_mfa_settings.up.sql @@ -0,0 +1,26 @@ +-- add enum representing location MFA configuration +CREATE TYPE location_mfa_mode AS ENUM ( + 'disabled', + 'internal', + 'external' +); + +-- add nullable column to `wireguard_network` table +ALTER TABLE wireguard_network ADD COLUMN "location_mfa_mode" location_mfa_mode DEFAULT 'disabled'; + +-- populate new column based on value in `mfa_enabled` column +-- previously only internal MFA was available +UPDATE wireguard_network +SET location_mfa_mode = CASE + WHEN mfa_enabled = true THEN 'internal'::location_mfa_mode + ELSE 'disabled'::location_mfa_mode +END; + +-- make new column NOT NULL +ALTER TABLE wireguard_network ALTER COLUMN "location_mfa_mode" SET NOT NULL; + +-- drop the `mfa_enabled` column since it's no longer needed +ALTER TABLE wireguard_network DROP COLUMN mfa_enabled; + +-- remove `use_openid_for_mfa` setting +ALTER TABLE settings DROP COLUMN use_openid_for_mfa; diff --git a/migrations/20250730141806_higher_peer_disconnect_threshold.down.sql b/migrations/20250730141806_higher_peer_disconnect_threshold.down.sql new file mode 100644 index 0000000000..be83412e91 --- /dev/null +++ b/migrations/20250730141806_higher_peer_disconnect_threshold.down.sql @@ -0,0 +1 @@ +ALTER TABLE wireguard_network ALTER COLUMN peer_disconnect_threshold SET DEFAULT 180; diff --git a/migrations/20250730141806_higher_peer_disconnect_threshold.up.sql b/migrations/20250730141806_higher_peer_disconnect_threshold.up.sql new file mode 100644 index 0000000000..af35bb2337 --- /dev/null +++ b/migrations/20250730141806_higher_peer_disconnect_threshold.up.sql @@ -0,0 +1 @@ +ALTER TABLE wireguard_network ALTER COLUMN peer_disconnect_threshold SET DEFAULT 300; 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 2f37d804d0..883487df67 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 2f37d804d074c68842262b55c78e536a464c4953 +Subproject commit 883487df67d90fd14fae900737cd8b5ea6c10de3 diff --git a/web/.biomeignore b/web/.biomeignore new file mode 100644 index 0000000000..8260f01a78 --- /dev/null +++ b/web/.biomeignore @@ -0,0 +1,2 @@ +src/i18n/*.ts +src/i18n/*.tsx diff --git a/web/.editorconfig b/web/.editorconfig index e24ee12ffc..57516b7ac9 100644 --- a/web/.editorconfig +++ b/web/.editorconfig @@ -23,6 +23,7 @@ indent_size = 2 rulers = 90 [*.{json,yaml}] +insert_final_newline = true indent_style = space indent_size = 2 rulers = 80 diff --git a/web/.env b/web/.env deleted file mode 100644 index 3b3c938afe..0000000000 --- a/web/.env +++ /dev/null @@ -1 +0,0 @@ -#PROXY_TARGET="https://defguard-dev.teonite.net" diff --git a/web/.gitignore b/web/.gitignore index 07c27a65ee..88e111584e 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1 +1,2 @@ *.local* +./*.env diff --git a/web/.nvmrc b/web/.nvmrc index 1c75bfa4d9..53a256a2ea 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -v23.7 +v24.7 diff --git a/web/.prettierignore b/web/.prettierignore index 13a3107463..74dbecd596 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,2 +1,4 @@ /src/i18n/*.ts /src/i18n/*.tsx +/src/**/*.tsx +/src/**/*.ts diff --git a/web/.prettierrc b/web/.prettierrc index 9a50f221fc..71a0f32917 100644 --- a/web/.prettierrc +++ b/web/.prettierrc @@ -3,5 +3,6 @@ "tabWidth": 2, "singleQuote": true, "useTabs": false, - "printWidth": 90 + "printWidth": 90, + "endOfLine": "lf" } diff --git a/web/biome.json b/web/biome.json new file mode 100644 index 0000000000..2bcbfd1d70 --- /dev/null +++ b/web/biome.json @@ -0,0 +1,84 @@ +{ + "$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"] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 90, + "attributePosition": "auto", + "bracketSameLine": false, + "bracketSpacing": true, + "expand": "auto", + "useEditorconfig": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": "off", + "complexity": { + "noBannedTypes": "error", + "noUselessTypeConstraint": "error" + }, + "correctness": { + "noChildrenProp": "error", + "noPrecisionLoss": "error", + "noUnusedVariables": "error", + "useExhaustiveDependencies": "error", + "useHookAtTopLevel": "error", + "useJsxKeyInIterable": "error", + "useUniqueElementIds": "off" + }, + "security": { "noDangerouslySetInnerHtmlWithChildren": "error" }, + "style": { + "noNamespace": "error", + "noNonNullAssertion": "error", + "useArrayLiterals": "error", + "useAsConstAssertion": "error", + "useBlockStatements": "off", + "useLiteralEnumMembers": "off" + }, + "suspicious": { + "noCommentText": "error", + "noDuplicateJsxProps": "error", + "noExplicitAny": "error", + "noExtraNonNullAssertion": "error", + "noMisleadingInstantiator": "error", + "noUnsafeDeclarationMerging": "error", + "noArrayIndexKey": "off" + } + } + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always", + "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/eslint.config.mjs b/web/eslint.config.mjs deleted file mode 100644 index cfc0d6cf41..0000000000 --- a/web/eslint.config.mjs +++ /dev/null @@ -1,84 +0,0 @@ -import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import prettierConfig from 'eslint-config-prettier/flat'; -import reactPlugin from 'eslint-plugin-react'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import simpleImportSort from 'eslint-plugin-simple-import-sort'; -import globals from 'globals'; - -export default tseslint.config( - { - ignores: [ - 'dist', - 'src/i18n/formatters.ts', - 'src/i18n/i18n-*', - 'build', - 'node_modules', - '**/svg', - ], - }, - eslint.configs.recommended, - tseslint.configs.recommendedTypeChecked, - // @ts-ignore - reactPlugin.configs.flat.recommended, - // @ts-ignore - reactPlugin.configs.flat['jsx-runtime'], - reactRefresh.configs.recommended, - { - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - project: ['./tsconfig.json', './tsconfig.app.json', './tsconfig.node.json'], - ecmaFeatures: { - jsx: true, - }, - }, - ecmaVersion: 'latest', - }, - }, - { - files: ['**/*.{js,cjs,mjs,jsx,ts,tsx,mtsx}'], - plugins: { - 'react-hooks': reactHooks, - 'simple-import-sort': simpleImportSort, - }, - languageOptions: { - globals: { - ...globals.serviceworker, - ...globals.browser, - }, - }, - settings: { - react: { - version: 'detect', - defaultVersion: '18.2', - }, - }, - // @ts-ignore - rules: { - ...reactHooks.configs.recommended.rules, - 'no-console': ['error', { allow: ['warn', 'error'] }], - '@typescript-eslint/unbound-method': 'off', - '@typescript-eslint/no-misused-promises': [ - 'error', - { - checksVoidReturn: { - attributes: false, - }, - }, - ], - 'react/display-name': 'off', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'error', - 'simple-import-sort/imports': 'error', - 'simple-import-sort/exports': 'error', - }, - }, - { - files: ['**/*.js'], - ...tseslint.configs.disableTypeChecked, - }, - prettierConfig, -); diff --git a/web/package.json b/web/package.json index aa94d524ef..2901ae2910 100644 --- a/web/package.json +++ b/web/package.json @@ -2,16 +2,19 @@ "name": "web", "type": "module", "scripts": { + "preview": "pnpm run build && pnpm run vite preview", "dev": "concurrently \"pnpm run vite\" \"pnpm run typesafe-i18n\"", - "build": "tsc && vite build", + "build": "pnpm run typecheck && vite build", "serve": "vite preview", + "typecheck": "tsc --project ./tsconfig.app.json", "generate-translation-types": "typesafe-i18n --no-watch", - "lint": "eslint src/ && prettier --check 'src/**/*.{ts,tsx,scss}' && tsc --noEmit", - "fix": "prettier -w 'src/**/*.{ts,tsx,scss}' && eslint --fix src/", + "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", - "eslint": "eslint", - "prettier": "prettier" + "prettier": "prettier", + "biome": "biome" }, "browserslist": { "production": [ @@ -40,63 +43,63 @@ ] }, "dependencies": { - "@floating-ui/react": "^0.27.12", + "@floating-ui/react": "^0.27.16", "@github/webauthn-json": "^2.1.1", - "@hookform/resolvers": "^5.0.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.80.6", - "@tanstack/react-query": "^5.80.6", - "@tanstack/react-virtual": "3.13.9", - "@tanstack/virtual-core": "3.13.9", + "@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.9.0", + "axios": "^1.11.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.16.0", "fuse.js": "^7.1.0", "get-text-width": "^1.0.3", "hex-rgb": "^5.0.0", - "html-react-parser": "^5.2.5", - "humanize-duration": "^3.32.2", + "html-react-parser": "^5.2.6", + "humanize-duration": "^3.33.0", "ipaddr.js": "^2.2.0", "itertools": "^2.4.1", + "js-base64": "^3.7.8", "lodash-es": "^4.17.21", - "merge-refs": "^1.3.0", + "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.0", - "react": "^18.3.1", + "radash": "^12.1.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.57.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.15", - "react-resize-detector": "^12.0.2", + "react-qr-code": "^2.0.18", + "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": "^2.15.3", + "recharts": "^3.2.0", "rehype-external-links": "^3.0.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -105,53 +108,38 @@ "text-case": "^1.2.4", "typesafe-i18n": "^5.26.2", "use-breakpoint": "^4.0.6", - "zod": "^3.25.51", - "zustand": "^5.0.5" + "zod": "^3.25.76", + "zustand": "^5.0.8" }, "devDependencies": { - "@babel/core": "^7.27.4", + "@babel/core": "^7.28.4", + "@biomejs/biome": "2.2.2", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", - "@eslint/js": "^9.28.0", "@hookform/devtools": "^4.4.0", - "@stylistic/eslint-plugin-ts": "^4.4.1", - "@tanstack/react-query-devtools": "^5.80.6", + "@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": "^22.15.29", + "@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", - "@typescript-eslint/eslint-plugin": "^8.33.1", - "@typescript-eslint/parser": "^8.33.1", - "@vitejs/plugin-react-swc": "^3.10.1", + "@vitejs/plugin-react-swc": "^4.0.1", "autoprefixer": "^10.4.21", - "concurrently": "^9.1.2", - "dotenv": "^16.5.0", - "esbuild": "^0.25.5", - "eslint": "^9.28.0", - "eslint-config-prettier": "^10.1.5", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-prettier": "^5.4.1", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "eslint-plugin-simple-import-sort": "^12.1.1", - "globals": "^16.2.0", - "postcss": "^8.5.4", - "prettier": "^3.5.3", + "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", - "typescript": "~5.8.3", - "typescript-eslint": "^8.33.1", - "typescript-eslint-language-service": "^5.0.5", - "vite": "^6.3.5", - "vite-plugin-eslint": "^1.8.1", + "type-fest": "^4.41.0", + "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 7c80ddca5c..029e2e51d9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -9,20 +9,20 @@ importers: .: dependencies: '@floating-ui/react': - specifier: ^0.27.12 - version: 0.27.12(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.0.1 - version: 5.0.1(react-hook-form@7.57.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.80.6 - version: 5.80.6 + specifier: ^5.87.1 + version: 5.87.1 '@tanstack/react-query': - specifier: ^5.80.6 - version: 5.80.6(react@18.3.1) + specifier: ^5.87.1 + version: 5.87.1(react@19.1.1) '@tanstack/react-virtual': - specifier: 3.13.9 - version: 3.13.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 3.13.12 + version: 3.13.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@tanstack/virtual-core': - specifier: 3.13.9 - version: 3.13.9 + 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.9.0 - version: 1.9.0 + specifier: ^1.11.0 + version: 1.11.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.16.0 - version: 12.16.0(@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 @@ -93,26 +90,32 @@ importers: specifier: ^5.0.0 version: 5.0.0 html-react-parser: - specifier: ^5.2.5 - version: 5.2.5(@types/react@18.3.23)(react@18.3.1) + specifier: ^5.2.6 + version: 5.2.6(@types/react@19.1.12)(react@19.1.1) humanize-duration: - specifier: ^3.32.2 - version: 3.32.2 + specifier: ^3.33.0 + version: 3.33.0 ipaddr.js: specifier: ^2.2.0 version: 2.2.0 itertools: specifier: ^2.4.1 version: 2.4.1 + js-base64: + specifier: ^3.7.8 + version: 3.7.8 lodash-es: specifier: ^4.17.21 version: 4.17.21 merge-refs: - specifier: ^1.3.0 - version: 1.3.0(@types/react@18.3.23) + specifier: ^2.0.0 + 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 @@ -123,62 +126,59 @@ importers: specifier: ^6.14.0 version: 6.14.0 radash: - specifier: ^12.1.0 - version: 12.1.0 + 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.57.0 - version: 7.57.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.15 - version: 2.0.15(react@18.3.1) + specifier: ^2.0.18 + version: 2.0.18(react@19.1.1) react-resize-detector: - specifier: ^12.0.2 - version: 12.0.2(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: ^2.15.3 - version: 2.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.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 @@ -199,38 +199,35 @@ 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.51 - version: 3.25.51 + specifier: ^3.25.76 + version: 3.25.76 zustand: - specifier: ^5.0.5 - version: 5.0.5(@types/react@18.3.23)(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.27.4 - version: 7.27.4 + specifier: ^7.28.4 + version: 7.28.4 + '@biomejs/biome': + 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) '@csstools/css-tokenizer': specifier: ^3.0.4 version: 3.0.4 - '@eslint/js': - specifier: ^9.28.0 - version: 9.28.0 '@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) - '@stylistic/eslint-plugin-ts': - specifier: ^4.4.1 - version: 4.4.1(eslint@9.28.0)(typescript@5.8.3) + 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.80.6 - version: 5.80.6(@tanstack/react-query@5.80.6(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,137 +241,95 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/node': - specifier: ^22.15.29 - version: 22.15.29 + 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 - '@typescript-eslint/eslint-plugin': - specifier: ^8.33.1 - version: 8.33.1(@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0)(typescript@5.8.3) - '@typescript-eslint/parser': - specifier: ^8.33.1 - version: 8.33.1(eslint@9.28.0)(typescript@5.8.3) '@vitejs/plugin-react-swc': - specifier: ^3.10.1 - version: 3.10.1(vite@6.3.5(@types/node@22.15.29)(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.4) + version: 10.4.21(postcss@8.5.6) concurrently: - specifier: ^9.1.2 - version: 9.1.2 + specifier: ^9.2.1 + version: 9.2.1 dotenv: - specifier: ^16.5.0 - version: 16.5.0 + specifier: ^17.2.2 + version: 17.2.2 esbuild: - specifier: ^0.25.5 - version: 0.25.5 - eslint: - specifier: ^9.28.0 - version: 9.28.0 - eslint-config-prettier: - specifier: ^10.1.5 - version: 10.1.5(eslint@9.28.0) - eslint-plugin-import: - specifier: ^2.31.0 - version: 2.31.0(@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0) - eslint-plugin-jsx-a11y: - specifier: ^6.10.2 - version: 6.10.2(eslint@9.28.0) - eslint-plugin-prettier: - specifier: ^5.4.1 - version: 5.4.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.28.0))(eslint@9.28.0)(prettier@3.5.3) - eslint-plugin-react: - specifier: ^7.37.5 - version: 7.37.5(eslint@9.28.0) - eslint-plugin-react-hooks: - specifier: ^5.2.0 - version: 5.2.0(eslint@9.28.0) - eslint-plugin-react-refresh: - specifier: ^0.4.20 - version: 0.4.20(eslint@9.28.0) - eslint-plugin-simple-import-sort: - specifier: ^12.1.1 - version: 12.1.1(eslint@9.28.0) + specifier: ^0.25.9 + version: 0.25.9 globals: - specifier: ^16.2.0 - version: 16.2.0 + specifier: ^16.4.0 + version: 16.4.0 postcss: - specifier: ^8.5.4 - version: 8.5.4 + specifier: ^8.5.6 + version: 8.5.6 prettier: - specifier: ^3.5.3 - version: 3.5.3 + specifier: ^3.6.2 + version: 3.6.2 sass: specifier: ~1.70.0 version: 1.70.0 standard-version: specifier: ^9.5.0 version: 9.5.0 + type-fest: + specifier: ^4.41.0 + version: 4.41.0 typescript: - specifier: ~5.8.3 - version: 5.8.3 - typescript-eslint: - specifier: ^8.33.1 - version: 8.33.1(eslint@9.28.0)(typescript@5.8.3) - typescript-eslint-language-service: - specifier: ^5.0.5 - version: 5.0.5(@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0)(typescript@5.8.3) + specifier: ~5.9.2 + version: 5.9.2 vite: - specifier: ^6.3.5 - version: 6.3.5(@types/node@22.15.29)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) - vite-plugin-eslint: - specifier: ^1.8.1 - version: 1.8.1(eslint@9.28.0)(vite@6.3.5(@types/node@22.15.29)(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@6.3.5(@types/node@22.15.29)(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.27.5': - resolution: {integrity: sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==} + '@babel/compat-data@7.28.4': + resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} engines: {node: '>=6.9.0'} - '@babel/core@7.27.4': - resolution: {integrity: sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==} + '@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.27.5': - resolution: {integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==} + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.27.2': resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.27.1': 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 @@ -391,31 +346,84 @@ 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.27.5': - resolution: {integrity: sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==} + '@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.27.4': - resolution: {integrity: sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==} + '@babel/traverse@7.28.4': + resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.27.6': - resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==} + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.2.2': + resolution: {integrity: sha512-j1omAiQWCkhuLgwpMKisNKnsM6W8Xtt1l0WZmqY/dFj8QPNkIoTvk4tSsi40FaAAkBE1PU0AFG2RWFBWenAn+w==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@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.2.2': + resolution: {integrity: sha512-Tn4JmVO+rXsbRslml7FvKaNrlgUeJot++FkvYIhl1OkslVCofAtS35MPlBMhXgKWF9RNr9cwHanrPTUUXcYGag==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@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.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.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.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.2.2': + resolution: {integrity: sha512-wBe2wItayw1zvtXysmHJQoQqXlTzHSpQRyPpJKiNIR21HzH/CrZRDFic1C1jDdp+zAPtqhNExa0owKMbNwW9cQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.2.2': + resolution: {integrity: sha512-DAuHhHekGfiGb6lCcsT4UyxQmVwQiBCBUMwVra/dcOSs9q8OhfaZgey51MlekT3p8UwRqtXQfFuEJBhJNdLZwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@csstools/css-parser-algorithms@3.0.5': resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} engines: {node: '>=18'} @@ -435,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==} @@ -456,8 +464,8 @@ packages: '@emotion/sheet@1.4.0': resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} - '@emotion/styled@11.14.0': - resolution: {integrity: sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==} + '@emotion/styled@11.14.1': + resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} peerDependencies: '@emotion/react': ^11.0.0-rc.0 '@types/react': '*' @@ -480,217 +488,186 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.25.5': - resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + '@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.5': - resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + '@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.5': - resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + '@esbuild/android-arm@0.25.9': + resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.5': - resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + '@esbuild/android-x64@0.25.9': + resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.5': - resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + '@esbuild/darwin-arm64@0.25.9': + resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.5': - resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + '@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.5': - resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + '@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.5': - resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + '@esbuild/freebsd-x64@0.25.9': + resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.5': - resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + '@esbuild/linux-arm64@0.25.9': + resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.5': - resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + '@esbuild/linux-arm@0.25.9': + resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.5': - resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + '@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.5': - resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + '@esbuild/linux-loong64@0.25.9': + resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.5': - resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + '@esbuild/linux-mips64el@0.25.9': + resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.5': - resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + '@esbuild/linux-ppc64@0.25.9': + resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.5': - resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + '@esbuild/linux-riscv64@0.25.9': + resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.5': - resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + '@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.5': - resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + '@esbuild/linux-x64@0.25.9': + resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.5': - resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + '@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.5': - resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + '@esbuild/netbsd-x64@0.25.9': + resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.5': - resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + '@esbuild/openbsd-arm64@0.25.9': + resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.5': - resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + '@esbuild/openbsd-x64@0.25.9': + resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.25.5': - resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + '@esbuild/openharmony-arm64@0.25.9': + resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.9': + resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.5': - resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + '@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.5': - resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + '@esbuild/win32-ia32@0.25.9': + resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.5': - resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + '@esbuild/win32-x64@0.25.9': + resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.7.0': - resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.20.0': - resolution: {integrity: sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.2.2': - resolution: {integrity: sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} - '@eslint/core@0.14.0': - resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.28.0': - resolution: {integrity: sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==} - 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.1': - resolution: {integrity: sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@floating-ui/core@1.7.1': - resolution: {integrity: sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==} - - '@floating-ui/dom@1.7.1': - resolution: {integrity: sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==} - - '@floating-ui/react-dom@2.1.3': - resolution: {integrity: sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA==} + '@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.12': - resolution: {integrity: sha512-kKlWNrpIQxF1B/a2MZvE0/uyKby4960yjO91W7nVyNKmmfNi62xU9HCjL1M1eWzx/LFj/VPSwJVbwQk9Pq/68A==} + '@floating-ui/react@0.27.16': + resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==} peerDependencies: react: '>=17.0.0' react-dom: '>=17.0.0' - '@floating-ui/utils@0.2.9': - resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} '@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': @@ -699,31 +676,11 @@ packages: react: ^16.8.0 || ^17 || ^18 || ^19 react-dom: ^16.8.0 || ^17 || ^18 || ^19 - '@hookform/resolvers@5.0.1': - resolution: {integrity: sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==} + '@hookform/resolvers@5.2.1': + resolution: {integrity: sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ==} peerDependencies: react-hook-form: ^7.55.0 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.6': - resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.3.1': - resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} - engines: {node: '>=18.18'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - '@hutson/parse-repository-url@3.0.2': resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} engines: {node: '>=6.9.0'} @@ -731,49 +688,22 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@jridgewell/gen-mapping@0.3.8': - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} - engines: {node: '>=6.0.0'} + '@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/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.11': resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - '@jridgewell/sourcemap-codec@1.5.0': - resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@pkgr/core@0.2.7': - resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@react-hook/latest@1.0.3': resolution: {integrity: sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==} peerDependencies: @@ -795,120 +725,129 @@ packages: react: '>=16.8.0' rxjs: '>=7' + '@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 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@remix-run/router@1.23.0': resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} engines: {node: '>=14.0.0'} - '@rolldown/pluginutils@1.0.0-beta.9': - resolution: {integrity: sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==} - - '@rollup/pluginutils@4.2.1': - resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} - engines: {node: '>= 8.0.0'} + '@rolldown/pluginutils@1.0.0-beta.32': + resolution: {integrity: sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==} - '@rollup/rollup-android-arm-eabi@4.41.1': - resolution: {integrity: sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==} + '@rollup/rollup-android-arm-eabi@4.50.1': + resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.41.1': - resolution: {integrity: sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==} + '@rollup/rollup-android-arm64@4.50.1': + resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.41.1': - resolution: {integrity: sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==} + '@rollup/rollup-darwin-arm64@4.50.1': + resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.41.1': - resolution: {integrity: sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==} + '@rollup/rollup-darwin-x64@4.50.1': + resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.41.1': - resolution: {integrity: sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==} + '@rollup/rollup-freebsd-arm64@4.50.1': + resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.41.1': - resolution: {integrity: sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==} + '@rollup/rollup-freebsd-x64@4.50.1': + resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.41.1': - resolution: {integrity: sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==} + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.41.1': - resolution: {integrity: sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==} + '@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.41.1': - resolution: {integrity: sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==} + '@rollup/rollup-linux-arm64-gnu@4.50.1': + resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.41.1': - resolution: {integrity: sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==} + '@rollup/rollup-linux-arm64-musl@4.50.1': + resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.41.1': - resolution: {integrity: sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==} + '@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.41.1': - resolution: {integrity: sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==} + '@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.41.1': - resolution: {integrity: sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==} + '@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.41.1': - resolution: {integrity: sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==} + '@rollup/rollup-linux-riscv64-musl@4.50.1': + resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.41.1': - resolution: {integrity: sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==} + '@rollup/rollup-linux-s390x-gnu@4.50.1': + resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.41.1': - resolution: {integrity: sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==} + '@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.41.1': - resolution: {integrity: sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==} + '@rollup/rollup-linux-x64-musl@4.50.1': + resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.41.1': - resolution: {integrity: sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==} + '@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.41.1': - resolution: {integrity: sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==} + '@rollup/rollup-win32-ia32-msvc@4.50.1': + resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.41.1': - resolution: {integrity: sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==} + '@rollup/rollup-win32-x64-msvc@4.50.1': + resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} cpu: [x64] os: [win32] - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@rx-state/core@0.1.4': resolution: {integrity: sha512-Z+3hjU2xh1HisLxt+W5hlYX/eGSDaXXP+ns82gq/PLZpkXLu0uwcNUh9RLY3Clq4zT+hSsA3vcpIGt6+UAb8rQ==} peerDependencies: @@ -938,77 +877,74 @@ packages: '@stablelib/x25519@2.0.1': resolution: {integrity: sha512-qi04HS2puHaBf50kM/kes5QcZFGsx8yF0YmCjLCOa/LPmnBaKEKX9ZR82OnnCwMn72YH13R/bBZgr/UP0aPFfA==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@stylistic/eslint-plugin-ts@4.4.1': - resolution: {integrity: sha512-2r6cLcmdF6til66lx8esBYvBvsn7xCmLT50gw/n1rGGlTq/OxeNjBIh4c3VEaDGMa/5TybrZTia6sQUHdIWx1w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: '>=9.0.0' - - '@swc/core-darwin-arm64@1.11.31': - resolution: {integrity: sha512-NTEaYOts0OGSbJZc0O74xsji+64JrF1stmBii6D5EevWEtrY4wlZhm8SiP/qPrOB+HqtAihxWIukWkP2aSdGSQ==} + '@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.11.31': - resolution: {integrity: sha512-THSGaSwT96JwXDwuXQ6yFBbn+xDMdyw7OmBpnweAWsh5DhZmQkALEm1DgdQO3+rrE99MkmzwAfclc0UmYro/OA==} + '@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.11.31': - resolution: {integrity: sha512-laKtQFnW7KHgE57Hx32os2SNAogcuIDxYE+3DYIOmDMqD7/1DCfJe6Rln2N9WcOw6HuDbDpyQavIwZNfSAa8vQ==} + '@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.11.31': - resolution: {integrity: sha512-T+vGw9aPE1YVyRxRr1n7NAdkbgzBzrXCCJ95xAZc/0+WUwmL77Z+js0J5v1KKTRxw4FvrslNCOXzMWrSLdwPSA==} + '@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.11.31': - resolution: {integrity: sha512-Mztp5NZkyd5MrOAG+kl+QSn0lL4Uawd4CK4J7wm97Hs44N9DHGIG5nOz7Qve1KZo407Y25lTxi/PqzPKHo61zQ==} + '@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.11.31': - resolution: {integrity: sha512-DDVE0LZcXOWwOqFU1Xi7gdtiUg3FHA0vbGb3trjWCuI1ZtDZHEQYL4M3/2FjqKZtIwASrDvO96w91okZbXhvMg==} + '@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.11.31': - resolution: {integrity: sha512-mJA1MzPPRIfaBUHZi0xJQ4vwL09MNWDeFtxXb0r4Yzpf0v5Lue9ymumcBPmw/h6TKWms+Non4+TDquAsweuKSw==} + '@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.11.31': - resolution: {integrity: sha512-RdtakUkNVAb/FFIMw3LnfNdlH1/ep6KgiPDRlmyUfd0WdIQ3OACmeBegEFNFTzi7gEuzy2Yxg4LWf4IUVk8/bg==} + '@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.11.31': - resolution: {integrity: sha512-hErXdCGsg7swWdG1fossuL8542I59xV+all751mYlBoZ8kOghLSKObGQTkBbuNvc0sUKWfWg1X0iBuIhAYar+w==} + '@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.11.31': - resolution: {integrity: sha512-5t7SGjUBMMhF9b5j17ml/f/498kiBJNf4vZFNM421UGUEETdtjPN9jZIuQrowBkoFGJTCVL/ECM4YRtTH30u/A==} + '@swc/core-win32-x64-msvc@1.13.5': + resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.11.31': - resolution: {integrity: sha512-mAby9aUnKRjMEA7v8cVZS9Ah4duoRBnX7X6r5qrhTxErx+68MoY1TPrVwj/66/SWN3Bl+jijqAqoB8Qx0QE34A==} + '@swc/core@1.13.5': + resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -1019,34 +955,34 @@ packages: '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/types@0.1.22': - resolution: {integrity: sha512-D13mY/ZA4PPEFSy6acki9eBT/3WgjMoRqNcdpIvjaYLQ44Xk5BdaL7UkDxAh6Z9UOe7tCCp67BVmZCojYp9owg==} + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@tanstack/query-core@5.80.6': - resolution: {integrity: sha512-nl7YxT/TAU+VTf+e2zTkObGTyY8YZBMnbgeA1ee66lIVqzKlYursAII6z5t0e6rXgwUMJSV4dshBTNacNpZHbQ==} + '@tanstack/query-core@5.87.1': + resolution: {integrity: sha512-HOFHVvhOCprrWvtccSzc7+RNqpnLlZ5R6lTmngb8aq7b4rc2/jDT0w+vLdQ4lD9bNtQ+/A4GsFXy030Gk4ollA==} - '@tanstack/query-devtools@5.80.0': - resolution: {integrity: sha512-D6gH4asyjaoXrCOt5vG5Og/YSj0D/TxwNQgtLJIgWbhbWCC/emu2E92EFoVHh4ppVWg1qT2gKHvKyQBEFZhCuA==} + '@tanstack/query-devtools@5.87.3': + resolution: {integrity: sha512-LkzxzSr2HS1ALHTgDmJH5eGAVsSQiuwz//VhFW5OqNk0OQ+Fsqba0Tsf+NzWRtXYvpgUqwQr4b2zdFZwxHcGvg==} - '@tanstack/react-query-devtools@5.80.6': - resolution: {integrity: sha512-y7Es0OJ4RYQxrPYsuuQP0jxjgJ40a03UbEPmJ6vwf/ERVMRoRIMkpjtvPxf1D+n9nwPfWmGdD0jW8Wxd+TxeEw==} + '@tanstack/react-query-devtools@5.87.3': + resolution: {integrity: sha512-uV7m4/m58jU4OaLEyiPLRoXnL5H5E598lhFLSXIcK83on+ZXW7aIfiu5kwRwe1qFa4X4thH8wKaxz1lt6jNmAA==} peerDependencies: - '@tanstack/react-query': ^5.80.6 + '@tanstack/react-query': ^5.87.1 react: ^18 || ^19 - '@tanstack/react-query@5.80.6': - resolution: {integrity: sha512-izX+5CnkpON3NQGcEm3/d7LfFQNo9ZpFtX2QsINgCYK9LT2VCIdi8D3bMaMSNhrAJCznRoAkFic76uvLroALBw==} + '@tanstack/react-query@5.87.1': + resolution: {integrity: sha512-YKauf8jfMowgAqcxj96AHs+Ux3m3bWT1oSVKamaRPXSnW2HqSznnTCEkAVqctF1e/W9R/mPcyzzINIgpOH94qg==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-virtual@3.13.9': - resolution: {integrity: sha512-SPWC8kwG/dWBf7Py7cfheAPOxuvIv4fFQ54PdmYbg7CpXfsKxkucak43Q0qKsxVthhUJQ1A7CIMAIplq4BjVwA==} + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} peerDependencies: 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 - '@tanstack/virtual-core@3.13.9': - resolution: {integrity: sha512-3jztt0jpaoJO5TARe2WIHC1UQC3VMLAFUW5mmMo0yrkwtDB2AQP0+sh10BVUpWrnvHjSLvzFizydtEGLCJKFoQ==} + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} '@types/byte-size@8.1.2': resolution: {integrity: sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==} @@ -1081,14 +1017,11 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/eslint@8.56.12': - resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==} - '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.7': - resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/file-saver@2.0.7': resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} @@ -1102,17 +1035,11 @@ packages: '@types/humanize-duration@3.27.4': resolution: {integrity: sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} - '@types/lodash@4.17.17': - resolution: {integrity: sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==} + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1123,8 +1050,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@22.15.29': - resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} + '@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==} @@ -1132,16 +1059,13 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} - '@types/prop-types@15.7.14': - resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} - '@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==} @@ -1149,11 +1073,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==} @@ -1161,64 +1082,8 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript-eslint/eslint-plugin@8.33.1': - resolution: {integrity: sha512-TDCXj+YxLgtvxvFlAvpoRv9MAncDLBV2oT9Bd7YBGC/b/sEURoOYuIwLI99rjWOfY3QtDzO+mk0n4AmdFExW8A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.33.1 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.9.0' - - '@typescript-eslint/parser@8.33.1': - resolution: {integrity: sha512-qwxv6dq682yVvgKKp2qWwLgRbscDAYktPptK4JPojCwwi3R9cwrvIxS4lvBpzmcqzR4bdn54Z0IG1uHFskW4dA==} - 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-eslint/project-service@8.33.1': - resolution: {integrity: sha512-DZR0efeNklDIHHGRpMpR5gJITQpu6tLr9lDJnKdONTC7vvzOlLAG/wcfxcdxEWrbiZApcoBCzXqU/Z458Za5Iw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <5.9.0' - - '@typescript-eslint/scope-manager@8.33.1': - resolution: {integrity: sha512-dM4UBtgmzHR9bS0Rv09JST0RcHYearoEoo3pG5B6GoTR9XcyeqX87FEhPo+5kTvVfKCvfHaHrcgeJQc6mrDKrA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.33.1': - resolution: {integrity: sha512-STAQsGYbHCF0/e+ShUQ4EatXQ7ceh3fBCXkNU7/MZVKulrlq1usH7t2FhxvCpuCi5O5oi1vmVaAjrGeL71OK1g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <5.9.0' - - '@typescript-eslint/type-utils@8.33.1': - resolution: {integrity: sha512-1cG37d9xOkhlykom55WVwG2QRNC7YXlxMaMzqw2uPeJixBFfKWZgaP/hjAObqMN/u3fr5BrTwTnc31/L9jQ2ww==} - 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-eslint/types@8.33.1': - resolution: {integrity: sha512-xid1WfizGhy/TKMTwhtVOgalHwPtV8T32MS9MaH50Cwvz6x6YqRIPdD2WvW0XaqOzTV9p5xdLY0h/ZusU5Lokg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.33.1': - resolution: {integrity: sha512-+s9LYcT8LWjdYWu7IWs7FvUxpQ/DGkdjZeE/GGulHvv8rvYwQvVaUZ6DE+j5x/prADUgSbbCWZ2nPI3usuVeOA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <5.9.0' - - '@typescript-eslint/utils@8.33.1': - resolution: {integrity: sha512-52HaBiEQUaRYqAXpfzWSR2U3gxk92Kw006+xZpElaPMg3C4PgM+A5LqwoQI1f9E5aZ/qlxAZxzm42WX+vn92SQ==} - 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-eslint/visitor-keys@8.33.1': - resolution: {integrity: sha512-3i8NrFcZeeDHJ+7ZUuDkGT+UHq+XoFGsymNK2jZCOHcfEzRQ0BdpRtdpSx/Iyf3MHLWIcLS0COuOPibKQboIiQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -1231,25 +1096,16 @@ packages: peerDependencies: react: '>= 16.8.0' - '@vitejs/plugin-react-swc@3.10.1': - resolution: {integrity: sha512-FmQvN3yZGyD9XW6IyxE86Kaa/DnxSsrDQX1xCR1qojNpBLaUop+nLYFvhCkJsq8zOupNjCRA9jyhPGOJsSkutA==} + '@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 + vite: ^4 || ^5 || ^6 || ^7 JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.14.1: - resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -1258,9 +1114,6 @@ packages: add-stream@1.0.0: resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==} - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1277,59 +1130,13 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlast@1.2.5: - resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - arrify@1.0.1: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} - ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1340,20 +1147,8 @@ packages: peerDependencies: postcss: ^8.1.0 - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axe-core@4.10.3: - resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} - engines: {node: '>=4'} - - axios@1.9.0: - resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} + axios@1.11.0: + resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} @@ -1365,25 +1160,22 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - bignumber.js@9.3.0: - resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.25.0: - resolution: {integrity: sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==} + browserslist@4.25.4: + resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1403,10 +1195,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -1423,8 +1211,8 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001721: - resolution: {integrity: sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==} + caniuse-lite@1.0.30001741: + resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1503,8 +1291,8 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} - concurrently@9.1.2: - resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==} + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} engines: {node: '>=18'} hasBin: true @@ -1591,10 +1379,6 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -1642,41 +1426,18 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} - damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - dargs@7.0.0: resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==} engines: {node: '>=8'} - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} dateformat@3.0.3: resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==} - dayjs@1.11.13: - resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} @@ -1698,24 +1459,13 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - decode-named-character-reference@1.1.0: - resolution: {integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + decode-named-character-reference@1.2.0: + resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} deepmerge-ts@7.1.5: resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} engines: {node: '>=16.0.0'} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1745,13 +1495,6 @@ packages: dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -1769,8 +1512,8 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} - dotenv@16.5.0: - resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + dotenv@17.2.2: + resolution: {integrity: sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==} engines: {node: '>=12'} dotgitignore@2.1.0: @@ -1781,30 +1524,23 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.165: - resolution: {integrity: sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==} + 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==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@6.0.0: - resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} - es-abstract@1.24.0: - resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} - engines: {node: '>= 0.4'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1813,10 +1549,6 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.2.1: - resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} - engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1825,16 +1557,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} + es-toolkit@1.39.10: + resolution: {integrity: sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==} - esbuild@0.25.5: - resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + esbuild@0.25.9: + resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} engines: {node: '>=18'} hasBin: true @@ -1850,138 +1577,11 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-prettier@10.1.5: - resolution: {integrity: sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - - eslint-module-utils@2.12.0: - resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-import@2.31.0: - resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - eslint-plugin-jsx-a11y@6.10.2: - resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - - eslint-plugin-prettier@5.4.1: - resolution: {integrity: sha512-9dF+KuU/Ilkq27A8idRP7N2DH8iUR6qXcjF3FR2wETY21PZdBrIjwCau8oboyGj9b7etWmTGEeM8e7oOed6ZWg==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true - - eslint-plugin-react-hooks@5.2.0: - resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} - engines: {node: '>=10'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - - eslint-plugin-react-refresh@0.4.20: - resolution: {integrity: sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==} - peerDependencies: - eslint: '>=8.40' - - eslint-plugin-react@7.37.5: - resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} - engines: {node: '>=4'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - - eslint-plugin-simple-import-sort@12.1.1: - resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} - peerDependencies: - eslint: '>=5.0.0' - - eslint-scope@8.3.0: - resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.0: - resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint@9.28.0: - resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.3.0: - resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} @@ -1993,28 +1593,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - - fast-equals@5.2.2: - resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==} - engines: {node: '>=6.0.0'} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - - fdir@6.4.5: - resolution: {integrity: sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -2025,10 +1606,6 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - file-saver@2.0.5: resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} @@ -2055,15 +1632,8 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - 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: '*' @@ -2071,10 +1641,6 @@ packages: debug: optional: true - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - form-data@4.0.4: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} @@ -2082,8 +1648,8 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - framer-motion@12.16.0: - resolution: {integrity: sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==} + framer-motion@12.23.12: + resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2104,13 +1670,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - fuse.js@7.1.0: resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} engines: {node: '>=10'} @@ -2136,10 +1695,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - get-text-width@1.0.3: resolution: {integrity: sha512-kv1MaexPcR/qaZ4kN8sUDjG5pRp5ptHvxcDGDBTeGld1cmo7MnlCMH22jevyvs/VV7Ran203o7qAOq2+kWw9cA==} @@ -2164,26 +1719,10 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} engines: {node: '>=18'} - globals@16.2.0: - resolution: {integrity: sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==} - engines: {node: '>=18'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2191,9 +1730,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -2203,10 +1739,6 @@ packages: resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} engines: {node: '>=6'} - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -2215,13 +1747,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2278,8 +1803,8 @@ packages: html-dom-parser@5.1.1: resolution: {integrity: sha512-+o4Y4Z0CLuyemeccvGN4bAO20aauB2N9tFEAep5x4OW34kV4PTarBHm6RL02afYt2BMKcr0D2Agep8S3nJPIBg==} - html-react-parser@5.2.5: - resolution: {integrity: sha512-bRPdv8KTqG9CEQPMNGksDqmbiRfVQeOidry8pVetdh/1jQ1Edx4KX5m0lWvDD89Pt4CqTYjK1BLz6NoNVxN/Uw==} + html-react-parser@5.2.6: + resolution: {integrity: sha512-qcpPWLaSvqXi+TndiHbCa+z8qt0tVzjMwFGFBAa41ggC+ZA5BHaMIeMJla9g3VSp4SmiZb9qyQbmbpHYpIfPOg==} peerDependencies: '@types/react': 0.14 || 15 || 16 || 17 || 18 || 19 react: 0.14 || 15 || 16 || 17 || 18 || 19 @@ -2296,16 +1821,11 @@ packages: htmlparser2@10.0.0: resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} - humanize-duration@3.32.2: - resolution: {integrity: sha512-jcTwWYeCJf4dN5GJnjBmHd42bNyK94lY49QTkrsAQrMTUoIYLevvDpmQtg5uv8ZrdIRIbzdasmSNZ278HHUPEg==} + humanize-duration@3.33.0: + resolution: {integrity: sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==} - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} + immer@10.1.3: + resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==} immutable@4.3.7: resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} @@ -2314,10 +1834,6 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -2331,10 +1847,6 @@ packages: inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -2353,45 +1865,17 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -2399,18 +1883,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-generator-function@1.1.0: - resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} - engines: {node: '>= 0.4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2418,18 +1894,6 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -2446,96 +1910,40 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - is-text-path@1.0.1: resolution: {integrity: sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==} engines: {node: '>=0.10.0'} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - iterator.prototype@1.1.5: - resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} - engines: {node: '>= 0.4'} - itertools@2.4.1: resolution: {integrity: sha512-dFTSYzmbfeNE3q/qxwAr/QdKsK6/rp+LTz8SJdTg1+lo9omXFYpDcOKw47/7TevlnC0LorR5pRSf68+yB3N0GA==} + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2545,28 +1953,10 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} - jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - language-subtag-registry@0.3.23: - resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} - - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2601,9 +1991,6 @@ packages: lodash.ismatch@4.4.0: resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==} - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -2657,25 +2044,18 @@ 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'} - merge-refs@1.3.0: - resolution: {integrity: sha512-nqXPXbso+1dcKDpPCXvwZyJILz+vSLqGGOnDrYHQYE+B8n9JTCekVLC65AfCpR4ggVyA/45Y0iR9LDyS2iI+zA==} + merge-refs@2.0.0: + resolution: {integrity: sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2739,10 +2119,6 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - millify@6.1.0: resolution: {integrity: sha512-H/E3J6t+DQs/F2YgfDhxUVZz/dF8JXPPKTLHL/yHCcLZLtCXJDUaqvhJXQwqOVBvbyNn4T0WjLpIHd7PAw7fBA==} hasBin: true @@ -2762,10 +2138,6 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -2777,11 +2149,25 @@ packages: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} - motion-dom@12.16.0: - resolution: {integrity: sha512-Z2nGwWrrdH4egLEtgYMCEN4V2qQt1qxlKy/uV7w691ztyA41Q5Rbn0KNGbsNVDZr9E8PD2IOQ3hSccRnB6xWzw==} + motion-dom@12.23.12: + resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} - motion-utils@12.12.1: - resolution: {integrity: sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==} + 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==} @@ -2794,14 +2180,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - 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==} @@ -2829,38 +2212,6 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - p-limit@1.3.0: resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} engines: {node: '>=4'} @@ -2923,10 +2274,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -2945,8 +2292,8 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.2: - resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} pify@2.3.0: @@ -2961,27 +2308,15 @@ packages: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.4: - resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier-linter-helpers@1.0.0: - resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} - engines: {node: '>=6.0.0'} - - prettier@3.5.3: - resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} hasBin: true @@ -3003,10 +2338,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - q@1.5.1: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} @@ -3027,15 +2358,12 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - quick-lru@4.0.1: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} - radash@12.1.0: - resolution: {integrity: sha512-b0Zcf09AhqKS83btmUeYBS8tFK7XL2e3RvLmZcm0sTdF1/UUlHSsjXdCcWNxe7yfmAlPve5ym0DmKGtTzP6kVQ==} + radash@12.1.1: + resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} engines: {node: '>=14.18.0'} react-click-away-listener@2.4.0: @@ -3044,19 +2372,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.57.0: - resolution: {integrity: sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==} + react-hook-form@7.62.0: + resolution: {integrity: sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -3079,11 +2407,8 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - 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==} @@ -3099,13 +2424,25 @@ packages: react-property@2.0.2: resolution: {integrity: sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==} - react-qr-code@2.0.15: - resolution: {integrity: sha512-MkZcjEXqVKqXEIMVE0mbcGgDpkfSdd8zhuzXEl9QzYeNcw8Hq2oVIzDLWuZN2PQBwM5PWjc2S31K8Q1UbcFMfw==} + react-qr-code@2.0.18: + resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==} peerDependencies: react: '*' - react-resize-detector@12.0.2: - resolution: {integrity: sha512-aAI4WxWAysWLhA8wKDpsS+PnnxQ0lWCkTlk2t+2ijalWvoSa7vPxmcKRLURkH+PU84QE4KP4dO58oVP3ypWkKA==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-resize-detector@12.3.0: + resolution: {integrity: sha512-mIDOVrTHKGnKe6qEUWi8dFdfHM5CPyTOpqoHctdMQf89Ljm/0qqDIzkP3vTRZZJi9/raaMiRxDEOqO4you5x+A==} peerDependencies: react: ^18.0.0 || ^19.0.0 @@ -3127,39 +2464,20 @@ packages: peerDependencies: react-dom: ^16.8.0 || ^17 || ^18 || ^19 - react-smooth@4.0.4: - resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} - peerDependencies: - 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-tracked@2.0.1: resolution: {integrity: sha512-qjbmtkO2IcW+rB2cFskRWDTjKs/w9poxvNnduacjQA04LWxOoLy9J8WfIEq1ahifQ/tVJQECrQPBm+UEzKRDtg==} peerDependencies: react: '>=18.0.0' scheduler: '>=0.19.0' - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - react-virtualized-auto-sizer@1.0.26: resolution: {integrity: sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==} peerDependencies: 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: @@ -3189,27 +2507,25 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - recharts-scale@0.4.5: - resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} - - recharts@2.15.3: - resolution: {integrity: sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==} - engines: {node: '>=14'} + recharts@3.2.0: + resolution: {integrity: sha512-fX0xCgNXo6mag9wz3oLuANR+dUQM4uIlTYBGTGq9CBRgW/8TZPzqPGYs5NTt8aENCf+i1CI8vqxT1py8L/5J2w==} + engines: {node: '>=18'} peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} rehype-external-links@3.0.0: resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==} @@ -3233,6 +2549,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3242,56 +2561,25 @@ packages: engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rollup@2.79.2: - resolution: {integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==} - engines: {node: '>=10.0.0'} - hasBin: true - - rollup@4.41.1: - resolution: {integrity: sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==} + rollup@4.50.1: + resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - sass@1.70.0: resolution: {integrity: sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==} 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==} @@ -3311,26 +2599,6 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - shell-quote@1.8.3: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} @@ -3378,8 +2646,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==} @@ -3392,37 +2660,10 @@ packages: engines: {node: '>=10'} hasBin: true - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string.prototype.includes@2.0.1: - resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} - engines: {node: '>= 0.4'} - - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} - - string.prototype.repeat@1.0.0: - resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -3448,15 +2689,11 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - style-to-js@1.1.16: - resolution: {integrity: sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==} + style-to-js@1.1.17: + resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==} - style-to-object@1.0.8: - resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==} + style-to-object@1.0.9: + resolution: {integrity: sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==} stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -3477,10 +2714,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.8: - resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} - engines: {node: ^14.18.0 || >=16.0.0} - tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} @@ -3565,8 +2798,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: @@ -3587,22 +2820,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - type-fest@0.18.1: resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==} engines: {node: '>=10'} @@ -3615,21 +2835,9 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -3640,22 +2848,8 @@ packages: peerDependencies: typescript: '>=3.5.1' - typescript-eslint-language-service@5.0.5: - resolution: {integrity: sha512-b7gWXpwSTqMVKpPX3WttNZEyVAMKs/2jsHKF79H+qaD6mjzCyU5jboJe/lOZgLJD+QRsXCr0GjIVxvl5kI1NMw==} - peerDependencies: - '@typescript-eslint/parser': '>= 5.0.0' - eslint: '>= 8.0.0' - typescript: '>= 4.0.0' - - typescript-eslint@8.33.1: - resolution: {integrity: sha512-AgRnV4sKkWOiZ0Kjbnf5ytTJXMUZQ0qhSVdQtDNYLPLnjsATEYhaO94GlRQwi4t4gO8FfjM6NnikHeKjUm8D7A==} - 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@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 @@ -3664,12 +2858,8 @@ packages: engines: {node: '>=0.8.0'} hasBin: true - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.10.0: + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -3695,9 +2885,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-breakpoint@4.0.6: resolution: {integrity: sha512-1s7vUjf36eeZYTgY1KkmPNXrTbKJVRA9cjBFQdYjK8+pDr0qJgH6/cuX5qQ2zcfkqxN5LieVd/DTVK6ofnwRTQ==} peerDependencies: @@ -3734,39 +2921,33 @@ 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==} - victory-vendor@36.9.2: - resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - - vite-plugin-eslint@1.8.1: - resolution: {integrity: sha512-PqdMf3Y2fLO9FsNPmMX+//2BF5SF8nEWspZdgl4kSt7UvHDRHVVfHvxsD7ULYzZrJDGRxR81Nq7TOFgwMnUang==} - peerDependencies: - eslint: '>=7' - vite: '>=2' + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} vite-plugin-package-version@1.1.0: resolution: {integrity: sha512-TPoFZXNanzcaKCIrC3e2L/TVRkkRLB6l4RPN/S7KbG7rWfyLcCEGsnXvxn6qR7fyZwXalnnSN/I9d6pSFjHpEA==} peerDependencies: vite: '>=2.0.0-beta.69' - vite@6.3.5: - resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vite@7.1.5: + resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@types/node': ^20.19.0 || >=22.12.0 jiti: '>=1.21.0' - less: '*' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 @@ -3797,34 +2978,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} - engines: {node: '>= 0.4'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -3890,11 +3046,11 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.25.51: - resolution: {integrity: sha512-TQSnBldh+XSGL+opiSIq0575wvDPqu09AqWe1F7JhUMKY+M91/aGlK4MhpVNO7MgYfHcVCB1ffwAUTJzllKJqg==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zustand@5.0.5: - resolution: {integrity: sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==} + zustand@5.0.8: + resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -3916,31 +3072,26 @@ packages: snapshots: - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 - '@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.27.5': {} + '@babel/compat-data@7.28.4': {} - '@babel/core@7.27.4': + '@babel/core@7.28.4': dependencies: - '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.5 + '@babel/generator': 7.28.3 '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.27.4) - '@babel/helpers': 7.27.6 - '@babel/parser': 7.27.5 + '@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.27.4 - '@babel/types': 7.27.6 + '@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 @@ -3949,35 +3100,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.27.5': + '@babel/generator@7.28.3': dependencies: - '@babel/parser': 7.27.5 - '@babel/types': 7.27.6 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@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.27.5 + '@babel/compat-data': 7.28.4 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.0 + browserslist: 4.25.4 lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-globals@7.28.0': {} + '@babel/helper-module-imports@7.27.1': dependencies: - '@babel/traverse': 7.27.4 - '@babel/types': 7.27.6 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.27.4)': + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': dependencies: - '@babel/core': 7.27.4 + '@babel/core': 7.28.4 '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.27.4 + '@babel/traverse': 7.28.4 transitivePeerDependencies: - supports-color @@ -3987,40 +3140,75 @@ 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.27.6 + '@babel/types': 7.28.4 - '@babel/parser@7.27.5': + '@babel/parser@7.28.4': dependencies: - '@babel/types': 7.27.6 + '@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.27.5 - '@babel/types': 7.27.6 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 - '@babel/traverse@7.27.4': + '@babel/traverse@7.28.4': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.5 - '@babel/parser': 7.27.5 + '@babel/generator': 7.28.3 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.4 '@babel/template': 7.27.2 - '@babel/types': 7.27.6 + '@babel/types': 7.28.4 debug: 4.4.1 - globals: 11.12.0 transitivePeerDependencies: - supports-color - '@babel/types@7.27.6': + '@babel/types@7.28.4': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@biomejs/biome@2.2.2': + optionalDependencies: + '@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.2.2': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.2.2': + optional: true + + '@biomejs/cli-linux-arm64@2.2.2': + optional: true + + '@biomejs/cli-linux-x64-musl@2.2.2': + optional: true + + '@biomejs/cli-linux-x64@2.2.2': + optional: true + + '@biomejs/cli-win32-arm64@2.2.2': + optional: true + + '@biomejs/cli-win32-x64@2.2.2': + optional: true + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-tokenizer': 3.0.4 @@ -4030,7 +3218,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 @@ -4053,25 +3241,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 @@ -4085,356 +3273,283 @@ snapshots: '@emotion/sheet@1.4.0': {} - '@emotion/styled@11.14.0(@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.5': + '@esbuild/aix-ppc64@0.25.9': optional: true - '@esbuild/android-arm64@0.25.5': + '@esbuild/android-arm64@0.25.9': optional: true - '@esbuild/android-arm@0.25.5': + '@esbuild/android-arm@0.25.9': optional: true - '@esbuild/android-x64@0.25.5': + '@esbuild/android-x64@0.25.9': optional: true - '@esbuild/darwin-arm64@0.25.5': + '@esbuild/darwin-arm64@0.25.9': optional: true - '@esbuild/darwin-x64@0.25.5': + '@esbuild/darwin-x64@0.25.9': optional: true - '@esbuild/freebsd-arm64@0.25.5': + '@esbuild/freebsd-arm64@0.25.9': optional: true - '@esbuild/freebsd-x64@0.25.5': + '@esbuild/freebsd-x64@0.25.9': optional: true - '@esbuild/linux-arm64@0.25.5': + '@esbuild/linux-arm64@0.25.9': optional: true - '@esbuild/linux-arm@0.25.5': + '@esbuild/linux-arm@0.25.9': optional: true - '@esbuild/linux-ia32@0.25.5': + '@esbuild/linux-ia32@0.25.9': optional: true - '@esbuild/linux-loong64@0.25.5': + '@esbuild/linux-loong64@0.25.9': optional: true - '@esbuild/linux-mips64el@0.25.5': + '@esbuild/linux-mips64el@0.25.9': optional: true - '@esbuild/linux-ppc64@0.25.5': + '@esbuild/linux-ppc64@0.25.9': optional: true - '@esbuild/linux-riscv64@0.25.5': + '@esbuild/linux-riscv64@0.25.9': optional: true - '@esbuild/linux-s390x@0.25.5': + '@esbuild/linux-s390x@0.25.9': optional: true - '@esbuild/linux-x64@0.25.5': + '@esbuild/linux-x64@0.25.9': optional: true - '@esbuild/netbsd-arm64@0.25.5': + '@esbuild/netbsd-arm64@0.25.9': optional: true - '@esbuild/netbsd-x64@0.25.5': + '@esbuild/netbsd-x64@0.25.9': optional: true - '@esbuild/openbsd-arm64@0.25.5': + '@esbuild/openbsd-arm64@0.25.9': optional: true - '@esbuild/openbsd-x64@0.25.5': + '@esbuild/openbsd-x64@0.25.9': optional: true - '@esbuild/sunos-x64@0.25.5': + '@esbuild/openharmony-arm64@0.25.9': optional: true - '@esbuild/win32-arm64@0.25.5': + '@esbuild/sunos-x64@0.25.9': optional: true - '@esbuild/win32-ia32@0.25.5': + '@esbuild/win32-arm64@0.25.9': optional: true - '@esbuild/win32-x64@0.25.5': + '@esbuild/win32-ia32@0.25.9': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.28.0)': - dependencies: - eslint: 9.28.0 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.1': {} - - '@eslint/config-array@0.20.0': - dependencies: - '@eslint/object-schema': 2.1.6 - debug: 4.4.1 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.2.2': {} - - '@eslint/core@0.14.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.1': - dependencies: - ajv: 6.12.6 - debug: 4.4.1 - espree: 10.3.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.28.0': {} - - '@eslint/object-schema@2.1.6': {} - - '@eslint/plugin-kit@0.3.1': - dependencies: - '@eslint/core': 0.14.0 - levn: 0.4.1 + '@esbuild/win32-x64@0.25.9': + optional: true - '@floating-ui/core@1.7.1': + '@floating-ui/core@1.7.3': dependencies: - '@floating-ui/utils': 0.2.9 + '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.7.1': + '@floating-ui/dom@1.7.4': dependencies: - '@floating-ui/core': 1.7.1 - '@floating-ui/utils': 0.2.9 + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.3(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.1 - 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.12(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.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@floating-ui/utils': 0.2.9 - react: 18.3.1 - react-dom: 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: 19.1.1 + react-dom: 19.1.1(react@19.1.1) tabbable: 6.2.0 - '@floating-ui/utils@0.2.9': {} + '@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.0(@emotion/react@11.14.0(@types/react@18.3.23)(react@18.3.1))(@types/react@18.3.23)(react@18.3.1) - '@types/lodash': 4.17.17 - little-state-machine: 4.8.1(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@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.0.1(react-hook-form@7.57.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.57.0(react@18.3.1) + react-hook-form: 7.62.0(react@19.1.1) - '@humanfs/core@0.19.1': {} + '@hutson/parse-repository-url@3.0.2': {} - '@humanfs/node@0.16.6': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.3.1 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.3.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@hutson/parse-repository-url@3.0.2': {} - - '@jridgewell/gen-mapping@0.3.13': + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.30 - optional: true - '@jridgewell/gen-mapping@0.3.8': + '@jridgewell/remapping@2.3.5': dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.11': dependencies: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.30 optional: true - '@jridgewell/sourcemap-codec@1.5.0': {} - - '@jridgewell/sourcemap-codec@1.5.5': - optional: true - - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.30': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - optional: true - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - '@pkgr/core@0.2.7': {} - - '@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) - '@remix-run/router@1.23.0': {} + '@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.3 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.1.1 + react-redux: 9.2.0(@types/react@19.1.12)(react@19.1.1)(redux@5.0.1) - '@rolldown/pluginutils@1.0.0-beta.9': {} + '@remix-run/router@1.23.0': {} - '@rollup/pluginutils@4.2.1': - dependencies: - estree-walker: 2.0.2 - picomatch: 2.3.1 + '@rolldown/pluginutils@1.0.0-beta.32': {} - '@rollup/rollup-android-arm-eabi@4.41.1': + '@rollup/rollup-android-arm-eabi@4.50.1': optional: true - '@rollup/rollup-android-arm64@4.41.1': + '@rollup/rollup-android-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-arm64@4.41.1': + '@rollup/rollup-darwin-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-x64@4.41.1': + '@rollup/rollup-darwin-x64@4.50.1': optional: true - '@rollup/rollup-freebsd-arm64@4.41.1': + '@rollup/rollup-freebsd-arm64@4.50.1': optional: true - '@rollup/rollup-freebsd-x64@4.41.1': + '@rollup/rollup-freebsd-x64@4.50.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.41.1': + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.41.1': + '@rollup/rollup-linux-arm-musleabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.41.1': + '@rollup/rollup-linux-arm64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.41.1': + '@rollup/rollup-linux-arm64-musl@4.50.1': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.41.1': + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.41.1': + '@rollup/rollup-linux-ppc64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.41.1': + '@rollup/rollup-linux-riscv64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.41.1': + '@rollup/rollup-linux-riscv64-musl@4.50.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.41.1': + '@rollup/rollup-linux-s390x-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.41.1': + '@rollup/rollup-linux-x64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-musl@4.41.1': + '@rollup/rollup-linux-x64-musl@4.50.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.41.1': + '@rollup/rollup-openharmony-arm64@4.50.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.41.1': + '@rollup/rollup-win32-arm64-msvc@4.50.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.41.1': + '@rollup/rollup-win32-ia32-msvc@4.50.1': optional: true - '@rtsao/scc@1.1.0': {} + '@rollup/rollup-win32-x64-msvc@4.50.1': + optional: true '@rx-state/core@0.1.4(rxjs@7.8.2)': dependencies: @@ -4467,92 +3582,84 @@ snapshots: '@stablelib/random': 2.0.1 '@stablelib/wipe': 2.0.1 - '@standard-schema/utils@0.3.0': {} + '@standard-schema/spec@1.0.0': {} - '@stylistic/eslint-plugin-ts@4.4.1(eslint@9.28.0)(typescript@5.8.3)': - dependencies: - '@typescript-eslint/utils': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - eslint: 9.28.0 - eslint-visitor-keys: 4.2.0 - espree: 10.3.0 - transitivePeerDependencies: - - supports-color - - typescript + '@standard-schema/utils@0.3.0': {} - '@swc/core-darwin-arm64@1.11.31': + '@swc/core-darwin-arm64@1.13.5': optional: true - '@swc/core-darwin-x64@1.11.31': + '@swc/core-darwin-x64@1.13.5': optional: true - '@swc/core-linux-arm-gnueabihf@1.11.31': + '@swc/core-linux-arm-gnueabihf@1.13.5': optional: true - '@swc/core-linux-arm64-gnu@1.11.31': + '@swc/core-linux-arm64-gnu@1.13.5': optional: true - '@swc/core-linux-arm64-musl@1.11.31': + '@swc/core-linux-arm64-musl@1.13.5': optional: true - '@swc/core-linux-x64-gnu@1.11.31': + '@swc/core-linux-x64-gnu@1.13.5': optional: true - '@swc/core-linux-x64-musl@1.11.31': + '@swc/core-linux-x64-musl@1.13.5': optional: true - '@swc/core-win32-arm64-msvc@1.11.31': + '@swc/core-win32-arm64-msvc@1.13.5': optional: true - '@swc/core-win32-ia32-msvc@1.11.31': + '@swc/core-win32-ia32-msvc@1.13.5': optional: true - '@swc/core-win32-x64-msvc@1.11.31': + '@swc/core-win32-x64-msvc@1.13.5': optional: true - '@swc/core@1.11.31': + '@swc/core@1.13.5': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.22 + '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.11.31 - '@swc/core-darwin-x64': 1.11.31 - '@swc/core-linux-arm-gnueabihf': 1.11.31 - '@swc/core-linux-arm64-gnu': 1.11.31 - '@swc/core-linux-arm64-musl': 1.11.31 - '@swc/core-linux-x64-gnu': 1.11.31 - '@swc/core-linux-x64-musl': 1.11.31 - '@swc/core-win32-arm64-msvc': 1.11.31 - '@swc/core-win32-ia32-msvc': 1.11.31 - '@swc/core-win32-x64-msvc': 1.11.31 + '@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.22': + '@swc/types@0.1.25': dependencies: '@swc/counter': 0.1.3 - '@tanstack/query-core@5.80.6': {} + '@tanstack/query-core@5.87.1': {} - '@tanstack/query-devtools@5.80.0': {} + '@tanstack/query-devtools@5.87.3': {} - '@tanstack/react-query-devtools@5.80.6(@tanstack/react-query@5.80.6(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.80.0 - '@tanstack/react-query': 5.80.6(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.80.6(react@18.3.1)': + '@tanstack/react-query@5.87.1(react@19.1.1)': dependencies: - '@tanstack/query-core': 5.80.6 - react: 18.3.1 + '@tanstack/query-core': 5.87.1 + react: 19.1.1 - '@tanstack/react-virtual@3.13.9(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.9 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@tanstack/virtual-core': 3.13.12 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) - '@tanstack/virtual-core@3.13.9': {} + '@tanstack/virtual-core@3.13.12': {} '@types/byte-size@8.1.2': {} @@ -4584,16 +3691,11 @@ snapshots: dependencies: '@types/ms': 2.1.0 - '@types/eslint@8.56.12': - dependencies: - '@types/estree': 1.0.7 - '@types/json-schema': 7.0.15 - '@types/estree-jsx@1.0.5': dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 - '@types/estree@1.0.7': {} + '@types/estree@1.0.8': {} '@types/file-saver@2.0.7': {} @@ -4605,15 +3707,11 @@ snapshots: '@types/humanize-duration@3.27.4': {} - '@types/json-schema@7.0.15': {} - - '@types/json5@0.0.29': {} - '@types/lodash-es@4.17.12': dependencies: - '@types/lodash': 4.17.17 + '@types/lodash': 4.17.20 - '@types/lodash@4.17.17': {} + '@types/lodash@4.17.20': {} '@types/mdast@4.0.4': dependencies: @@ -4623,152 +3721,55 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@22.15.29': + '@types/node@24.3.1': dependencies: - undici-types: 6.21.0 + undici-types: 7.10.0 '@types/normalize-package-data@2.4.4': {} '@types/parse-json@4.0.2': {} - '@types/prop-types@15.7.14': {} - '@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-window@1.8.8': - dependencies: - '@types/react': 18.3.23 + '@types/react': 19.1.12 - '@types/react@18.3.23': + '@types/react@19.1.12': dependencies: - '@types/prop-types': 15.7.14 csstype: 3.1.3 '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} - '@typescript-eslint/eslint-plugin@8.33.1(@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0)(typescript@5.8.3)': - dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - '@typescript-eslint/scope-manager': 8.33.1 - '@typescript-eslint/type-utils': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - '@typescript-eslint/utils': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.33.1 - eslint: 9.28.0 - graphemer: 1.4.0 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.33.1 - '@typescript-eslint/types': 8.33.1 - '@typescript-eslint/typescript-estree': 8.33.1(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.33.1 - debug: 4.4.1 - eslint: 9.28.0 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.33.1(typescript@5.8.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.33.1(typescript@5.8.3) - '@typescript-eslint/types': 8.33.1 - debug: 4.4.1 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.33.1': - dependencies: - '@typescript-eslint/types': 8.33.1 - '@typescript-eslint/visitor-keys': 8.33.1 - - '@typescript-eslint/tsconfig-utils@8.33.1(typescript@5.8.3)': - dependencies: - typescript: 5.8.3 - - '@typescript-eslint/type-utils@8.33.1(eslint@9.28.0)(typescript@5.8.3)': - dependencies: - '@typescript-eslint/typescript-estree': 8.33.1(typescript@5.8.3) - '@typescript-eslint/utils': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - debug: 4.4.1 - eslint: 9.28.0 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.33.1': {} - - '@typescript-eslint/typescript-estree@8.33.1(typescript@5.8.3)': - dependencies: - '@typescript-eslint/project-service': 8.33.1(typescript@5.8.3) - '@typescript-eslint/tsconfig-utils': 8.33.1(typescript@5.8.3) - '@typescript-eslint/types': 8.33.1 - '@typescript-eslint/visitor-keys': 8.33.1 - debug: 4.4.1 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.33.1(eslint@9.28.0)(typescript@5.8.3)': - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0) - '@typescript-eslint/scope-manager': 8.33.1 - '@typescript-eslint/types': 8.33.1 - '@typescript-eslint/typescript-estree': 8.33.1(typescript@5.8.3) - eslint: 9.28.0 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.33.1': - dependencies: - '@typescript-eslint/types': 8.33.1 - eslint-visitor-keys: 4.2.0 + '@types/use-sync-external-store@0.0.6': {} '@ungap/structured-clone@1.3.0': {} '@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.10.1(vite@6.3.5(@types/node@22.15.29)(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.9 - '@swc/core': 1.11.31 - vite: 6.3.5(@types/node@22.15.29)(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' @@ -4777,24 +3778,11 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 - acorn-jsx@5.3.2(acorn@8.14.1): - dependencies: - acorn: 8.14.1 - - acorn@8.14.1: {} - acorn@8.15.0: optional: true add-stream@1.0.0: {} - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ansi-regex@5.0.1: {} ansi-styles@3.2.1: @@ -4810,116 +3798,33 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - argparse@2.0.1: {} - - aria-query@5.3.2: {} - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - array-ify@1.0.0: {} - array-includes@3.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array.prototype.findlast@1.2.5: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-shim-unscopables: 1.1.0 - - array.prototype.tosorted@1.1.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - arrify@1.0.1: {} - ast-types-flow@0.0.8: {} - - async-function@1.0.0: {} - asynckit@0.4.0: {} - autoprefixer@10.4.21(postcss@8.5.4): + autoprefixer@10.4.21(postcss@8.5.6): dependencies: - browserslist: 4.25.0 - caniuse-lite: 1.0.30001721 + 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.4 + postcss: 8.5.6 postcss-value-parser: 4.2.0 - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - axe-core@4.10.3: {} - - axios@1.9.0: + axios@1.11.0: dependencies: - follow-redirects: 1.15.9 + follow-redirects: 1.15.11 form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug - axobject-query@4.1.0: {} - 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 @@ -4927,29 +3832,25 @@ snapshots: balanced-match@1.0.2: {} - bignumber.js@9.3.0: {} + bignumber.js@9.3.1: {} binary-extensions@2.3.0: {} - brace-expansion@1.1.11: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.1: - dependencies: - balanced-match: 1.0.2 - braces@3.0.3: dependencies: fill-range: 7.1.1 - browserslist@4.25.0: + browserslist@4.25.4: dependencies: - caniuse-lite: 1.0.30001721 - electron-to-chromium: 1.5.165 - node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.25.0) + 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: {} @@ -4960,13 +3861,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4982,7 +3876,7 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001721: {} + caniuse-lite@1.0.30001741: {} ccount@2.0.1: {} @@ -5074,10 +3968,9 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 - concurrently@9.1.2: + 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 @@ -5210,12 +4103,6 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - csstype@3.1.3: {} d3-array@3.2.4: @@ -5256,37 +4143,13 @@ snapshots: d3-timer@3.0.1: {} - damerau-levenshtein@1.0.8: {} - dargs@7.0.0: {} - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - date-fns@4.1.0: {} dateformat@3.0.3: {} - dayjs@1.11.13: {} - - debug@3.2.7: - dependencies: - ms: 2.1.3 + dayjs@1.11.18: {} debug@4.4.1: dependencies: @@ -5301,26 +4164,12 @@ snapshots: decimal.js-light@2.5.1: {} - decode-named-character-reference@1.1.0: + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 - deep-is@0.1.4: {} - deepmerge-ts@7.1.5: {} - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - delayed-stream@1.0.0: {} dequal@2.0.3: {} @@ -5341,15 +4190,6 @@ snapshots: dijkstrajs@1.0.3: {} - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.27.6 - csstype: 3.1.3 - dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -5372,7 +4212,7 @@ snapshots: dependencies: is-obj: 2.0.0 - dotenv@16.5.0: {} + dotenv@17.2.2: {} dotgitignore@2.1.0: dependencies: @@ -5385,100 +4225,22 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.165: {} + electron-to-chromium@1.5.215: {} emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - entities@4.5.0: {} - entities@6.0.0: {} + entities@6.0.1: {} error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 - es-abstract@1.24.0: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.19 - es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-set-tostringtag: 2.1.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - iterator.prototype: 1.1.5 - safe-array-concat: 1.1.3 - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -5490,43 +4252,36 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 + es-toolkit@1.39.10: {} - esbuild@0.25.5: + esbuild@0.25.9: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.5 - '@esbuild/android-arm': 0.25.5 - '@esbuild/android-arm64': 0.25.5 - '@esbuild/android-x64': 0.25.5 - '@esbuild/darwin-arm64': 0.25.5 - '@esbuild/darwin-x64': 0.25.5 - '@esbuild/freebsd-arm64': 0.25.5 - '@esbuild/freebsd-x64': 0.25.5 - '@esbuild/linux-arm': 0.25.5 - '@esbuild/linux-arm64': 0.25.5 - '@esbuild/linux-ia32': 0.25.5 - '@esbuild/linux-loong64': 0.25.5 - '@esbuild/linux-mips64el': 0.25.5 - '@esbuild/linux-ppc64': 0.25.5 - '@esbuild/linux-riscv64': 0.25.5 - '@esbuild/linux-s390x': 0.25.5 - '@esbuild/linux-x64': 0.25.5 - '@esbuild/netbsd-arm64': 0.25.5 - '@esbuild/netbsd-x64': 0.25.5 - '@esbuild/openbsd-arm64': 0.25.5 - '@esbuild/openbsd-x64': 0.25.5 - '@esbuild/sunos-x64': 0.25.5 - '@esbuild/win32-arm64': 0.25.5 - '@esbuild/win32-ia32': 0.25.5 - '@esbuild/win32-x64': 0.25.5 + '@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: {} @@ -5534,192 +4289,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.5(eslint@9.28.0): - dependencies: - eslint: 9.28.0 - - eslint-import-resolver-node@0.3.9: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.10 - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.28.0): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - eslint: 9.28.0 - eslint-import-resolver-node: 0.3.9 - transitivePeerDependencies: - - supports-color - - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.28.0 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.28.0) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-jsx-a11y@6.10.2(eslint@9.28.0): - dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.10.3 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 9.28.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - - eslint-plugin-prettier@5.4.1(@types/eslint@8.56.12)(eslint-config-prettier@10.1.5(eslint@9.28.0))(eslint@9.28.0)(prettier@3.5.3): - dependencies: - eslint: 9.28.0 - prettier: 3.5.3 - prettier-linter-helpers: 1.0.0 - synckit: 0.11.8 - optionalDependencies: - '@types/eslint': 8.56.12 - eslint-config-prettier: 10.1.5(eslint@9.28.0) - - eslint-plugin-react-hooks@5.2.0(eslint@9.28.0): - dependencies: - eslint: 9.28.0 - - eslint-plugin-react-refresh@0.4.20(eslint@9.28.0): - dependencies: - eslint: 9.28.0 - - eslint-plugin-react@7.37.5(eslint@9.28.0): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.1 - eslint: 9.28.0 - estraverse: 5.3.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.5 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - - eslint-plugin-simple-import-sort@12.1.1(eslint@9.28.0): - dependencies: - eslint: 9.28.0 - - eslint-scope@8.3.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.0: {} - - eslint@9.28.0: - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0) - '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.20.0 - '@eslint/config-helpers': 0.2.2 - '@eslint/core': 0.14.0 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.28.0 - '@eslint/plugin-kit': 0.3.1 - '@humanfs/node': 0.16.6 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.7 - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.1 - escape-string-regexp: 4.0.0 - eslint-scope: 8.3.0 - eslint-visitor-keys: 4.2.0 - espree: 10.3.0 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@10.3.0: - dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) - eslint-visitor-keys: 4.2.0 - - esquery@1.6.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - estree-util-is-identifier-name@3.0.0: {} - estree-walker@2.0.2: {} - - esutils@2.0.3: {} - - eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} events@3.3.0: {} @@ -5727,38 +4299,14 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - - fast-equals@5.2.2: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - - fdir@6.4.5(picomatch@4.0.2): + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: - picomatch: 4.0.2 + picomatch: 4.0.3 figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - file-saver@2.0.5: {} fill-range@7.1.1: @@ -5785,18 +4333,7 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@4.0.1: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - - flatted@3.3.3: {} - - follow-redirects@1.15.9: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 + follow-redirects@1.15.11: {} form-data@4.0.4: dependencies: @@ -5808,32 +4345,21 @@ snapshots: fraction.js@4.3.7: {} - framer-motion@12.16.0(@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.16.0 - motion-utils: 12.12.1 + 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 function-bind@1.1.2: {} - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - fuse.js@7.1.0: {} gensync@1.0.0-beta.2: {} @@ -5865,12 +4391,6 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - get-text-width@1.0.3: {} git-raw-commits@2.0.11: @@ -5899,27 +4419,12 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@11.12.0: {} - - globals@14.0.0: {} - - globals@16.2.0: {} - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 + globals@16.4.0: {} gopd@1.2.0: {} graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -5931,20 +4436,10 @@ snapshots: hard-rejection@2.1.0: {} - has-bigints@1.1.0: {} - has-flag@3.0.0: {} has-flag@4.0.0: {} - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -5998,7 +4493,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 '@types/hast': 3.0.4 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 @@ -6010,9 +4505,9 @@ snapshots: mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 - style-to-js: 1.1.16 + 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 @@ -6055,15 +4550,15 @@ snapshots: domhandler: 5.0.3 htmlparser2: 10.0.0 - html-react-parser@5.2.5(@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.16 + style-to-js: 1.1.17 optionalDependencies: - '@types/react': 18.3.23 + '@types/react': 19.1.12 html-url-attributes@3.0.1: {} @@ -6074,13 +4569,11 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 domutils: 3.2.2 - entities: 6.0.0 - - humanize-duration@3.32.2: {} + entities: 6.0.1 - ignore@5.3.2: {} + humanize-duration@3.33.0: {} - ignore@7.0.5: {} + immer@10.1.3: {} immutable@4.3.7: {} @@ -6089,8 +4582,6 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - imurmurhash@0.1.4: {} - indent-string@4.0.0: {} inherits@2.0.4: {} @@ -6099,12 +4590,6 @@ snapshots: inline-style-parser@0.2.4: {} - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 - internmap@2.0.3: {} ipaddr.js@2.2.0: {} @@ -6118,209 +4603,70 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-arrayish@0.2.1: {} - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-callable@1.2.7: {} - is-core-module@2.16.1: dependencies: hasown: 2.0.2 - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-decimal@2.0.1: {} - - is-extglob@2.1.1: {} - - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-fullwidth-code-point@3.0.0: {} - - is-generator-function@1.1.0: - dependencies: - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-hexadecimal@2.0.1: {} - - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-number@7.0.0: {} - - is-obj@2.0.0: {} - - is-plain-obj@1.1.0: {} - - is-plain-obj@4.1.0: {} - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - is-set@2.0.3: {} + is-decimal@2.0.1: {} - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 + is-extglob@2.1.1: {} - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 + is-fullwidth-code-point@3.0.0: {} - is-symbol@1.1.1: + is-glob@4.0.3: dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 + is-extglob: 2.1.1 - is-text-path@1.0.1: - dependencies: - text-extensions: 1.9.0 + is-hexadecimal@2.0.1: {} - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.19 + is-number@7.0.0: {} - is-weakmap@2.0.2: {} + is-obj@2.0.0: {} - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 + is-plain-obj@1.1.0: {} + + is-plain-obj@4.1.0: {} - is-weakset@2.0.4: + is-text-path@1.0.1: dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 + text-extensions: 1.9.0 isarray@1.0.0: {} - isarray@2.0.5: {} - - isexe@2.0.0: {} + itertools@2.4.1: {} - iterator.prototype@1.1.5: - dependencies: - define-data-property: 1.1.4 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - has-symbols: 1.1.0 - set-function-name: 2.0.2 + jiti@2.4.2: + optional: true - itertools@2.4.1: {} + js-base64@3.7.8: {} js-tokens@4.0.0: {} - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - jsesc@3.1.0: {} - json-buffer@3.0.1: {} - json-parse-better-errors@1.0.2: {} json-parse-even-better-errors@2.3.1: {} - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - json-stringify-safe@5.0.1: {} - json5@1.0.2: - dependencies: - minimist: 1.2.8 - json5@2.2.3: {} jsonparse@1.3.1: {} - jsx-ast-utils@3.3.5: - dependencies: - array-includes: 3.1.9 - array.prototype.flat: 1.3.3 - object.assign: 4.1.7 - object.values: 1.2.1 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - kind-of@6.0.3: {} - language-subtag-registry@0.3.23: {} - - language-tags@1.0.9: - dependencies: - language-subtag-registry: 0.3.23 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - 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: @@ -6351,8 +4697,6 @@ snapshots: lodash.ismatch@4.4.0: {} - lodash.merge@4.6.2: {} - lodash@4.17.21: {} longest-streak@3.1.0: {} @@ -6379,7 +4723,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 - decode-named-character-reference: 1.1.0 + decode-named-character-reference: 1.2.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 micromark: 4.0.2 @@ -6416,7 +4760,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 @@ -6464,8 +4808,6 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - memoize-one@5.2.1: {} - meow@8.1.2: dependencies: '@types/minimist': 1.2.5 @@ -6480,15 +4822,13 @@ snapshots: type-fest: 0.18.1 yargs-parser: 20.2.9 - merge-refs@1.3.0(@types/react@18.3.23): + merge-refs@2.0.0(@types/react@19.1.12): optionalDependencies: - '@types/react': 18.3.23 - - merge2@1.4.1: {} + '@types/react': 19.1.12 micromark-core-commonmark@2.0.3: dependencies: - decode-named-character-reference: 1.1.0 + decode-named-character-reference: 1.2.0 devlop: 1.1.0 micromark-factory-destination: 2.0.1 micromark-factory-label: 2.0.1 @@ -6563,7 +4903,7 @@ snapshots: micromark-util-decode-string@2.0.1: dependencies: - decode-named-character-reference: 1.1.0 + decode-named-character-reference: 1.2.0 micromark-util-character: 2.1.1 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-symbol: 2.0.1 @@ -6601,7 +4941,7 @@ snapshots: dependencies: '@types/debug': 4.1.12 debug: 4.4.1 - decode-named-character-reference: 1.1.0 + decode-named-character-reference: 1.2.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 micromark-factory-space: 2.0.1 @@ -6619,11 +4959,6 @@ snapshots: transitivePeerDependencies: - supports-color - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - millify@6.1.0: dependencies: yargs: 17.7.2 @@ -6638,11 +4973,7 @@ snapshots: minimatch@3.1.2: dependencies: - brace-expansion: 1.1.11 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.1 + brace-expansion: 1.1.12 minimist-options@4.1.0: dependencies: @@ -6654,11 +4985,20 @@ snapshots: modify-values@1.0.1: {} - motion-dom@12.16.0: + motion-dom@12.23.12: dependencies: - motion-utils: 12.12.1 + motion-utils: 12.23.6 - motion-utils@12.12.1: {} + 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: {} @@ -6666,11 +5006,9 @@ snapshots: nanoid@3.3.11: {} - natural-compare@1.4.0: {} - neo-async@2.6.2: {} - node-releases@2.0.19: {} + node-releases@2.0.20: {} normalize-package-data@2.5.0: dependencies: @@ -6692,65 +5030,12 @@ snapshots: numbro@2.5.0: dependencies: - bignumber.js: 9.3.0 + bignumber.js: 9.3.1 object-assign@4.1.1: {} object-inspect@1.13.4: {} - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.entries@1.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - p-limit@1.3.0: dependencies: p-try: 1.0.0 @@ -6792,7 +5077,7 @@ snapshots: '@types/unist': 2.0.11 character-entities-legacy: 3.0.0 character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.1.0 + decode-named-character-reference: 1.2.0 is-alphanumerical: 2.0.1 is-decimal: 2.0.1 is-hexadecimal: 2.0.1 @@ -6811,14 +5096,12 @@ snapshots: parse5@7.3.0: dependencies: - entities: 6.0.0 + entities: 6.0.1 path-exists@3.0.0: {} path-exists@4.0.0: {} - path-key@3.1.1: {} - path-parse@1.0.7: {} path-type@3.0.0: @@ -6831,7 +5114,7 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.2: {} + picomatch@4.0.3: {} pify@2.3.0: {} @@ -6839,23 +5122,15 @@ snapshots: pngjs@5.0.0: {} - possible-typed-array-names@1.1.0: {} - postcss-value-parser@4.2.0: {} - postcss@8.5.4: + postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - prelude-ls@1.2.1: {} - - prettier-linter-helpers@1.0.0: - dependencies: - fast-diff: 1.3.0 - - prettier@3.5.3: {} + prettier@3.6.2: {} process-nextick-args@2.0.1: {} @@ -6873,8 +5148,6 @@ snapshots: proxy-from-env@1.1.0: {} - punycode@2.3.1: {} - q@1.5.1: {} qr.js@0.0.0: {} @@ -6889,66 +5162,61 @@ snapshots: dependencies: side-channel: 1.1.0 - queue-microtask@1.2.3: {} - quick-lru@4.0.1: {} - radash@12.1.0: {} + 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.12(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.57.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@18.3.1: {} + react-is@19.1.1: {} - react-is@19.1.0: {} - - 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 @@ -6959,72 +5227,55 @@ snapshots: react-property@2.0.2: {} - react-qr-code@2.0.15(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-resize-detector@12.0.2(react@18.3.1): + react-redux@9.2.0(@types/react@19.1.12)(react@19.1.1)(redux@5.0.1): dependencies: - lodash: 4.17.21 - react: 18.3.1 + '@types/use-sync-external-store': 0.0.6 + react: 19.1.1 + use-sync-external-store: 1.5.0(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + redux: 5.0.1 - react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-resize-detector@12.3.0(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) + es-toolkit: 1.39.10 + react: 19.1.1 - react-router@6.30.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: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-router: 6.30.1(react@19.1.1) - react-simple-animate@3.5.3(react-dom@18.3.1(react@18.3.1)): + react-router@6.30.1(react@19.1.1): dependencies: - react-dom: 18.3.1(react@18.3.1) + '@remix-run/router': 1.23.0 + react: 19.1.1 - react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-simple-animate@3.5.3(react-dom@19.1.1(react@19.1.1)): dependencies: - fast-equals: 5.2.2 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-transition-group: 4.4.5(react-dom@18.3.1(react@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) - - react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.27.6 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react-virtualized-auto-sizer@1.0.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + use-context-selector: 2.0.0(react@19.1.1)(scheduler@0.26.0) - react-window@1.8.11(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: - '@babel/runtime': 7.27.6 - memoize-one: 5.2.1 - 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@18.3.1: - dependencies: - loose-envify: 1.4.0 + react@19.1.1: {} read-pkg-up@3.0.0: dependencies: @@ -7070,47 +5321,36 @@ snapshots: dependencies: picomatch: 2.3.1 - recharts-scale@0.4.5: - dependencies: - decimal.js-light: 2.5.1 - - recharts@2.15.3(react-dom@18.3.1(react@18.3.1))(react@18.3.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.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 - eventemitter3: 4.0.7 - lodash: 4.17.21 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - recharts-scale: 0.4.5 + decimal.js-light: 2.5.1 + es-toolkit: 1.39.10 + eventemitter3: 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 - victory-vendor: 36.9.2 + use-sync-external-store: 1.5.0(react@19.1.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux redent@3.0.0: dependencies: indent-string: 4.0.0 strip-indent: 3.0.0 - reflect.getprototypeof@1.0.10: + redux-thunk@3.1.0(redux@5.0.1): dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 + redux: 5.0.1 - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 + redux@5.0.1: {} rehype-external-links@3.0.0: dependencies: @@ -7153,6 +5393,8 @@ snapshots: require-main-filename@2.0.0: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve@1.22.10: @@ -7161,85 +5403,47 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.5: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - - rollup@2.79.2: - optionalDependencies: - fsevents: 2.3.3 - - rollup@4.41.1: + rollup@4.50.1: dependencies: - '@types/estree': 1.0.7 + '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.41.1 - '@rollup/rollup-android-arm64': 4.41.1 - '@rollup/rollup-darwin-arm64': 4.41.1 - '@rollup/rollup-darwin-x64': 4.41.1 - '@rollup/rollup-freebsd-arm64': 4.41.1 - '@rollup/rollup-freebsd-x64': 4.41.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.41.1 - '@rollup/rollup-linux-arm-musleabihf': 4.41.1 - '@rollup/rollup-linux-arm64-gnu': 4.41.1 - '@rollup/rollup-linux-arm64-musl': 4.41.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.41.1 - '@rollup/rollup-linux-powerpc64le-gnu': 4.41.1 - '@rollup/rollup-linux-riscv64-gnu': 4.41.1 - '@rollup/rollup-linux-riscv64-musl': 4.41.1 - '@rollup/rollup-linux-s390x-gnu': 4.41.1 - '@rollup/rollup-linux-x64-gnu': 4.41.1 - '@rollup/rollup-linux-x64-musl': 4.41.1 - '@rollup/rollup-win32-arm64-msvc': 4.41.1 - '@rollup/rollup-win32-ia32-msvc': 4.41.1 - '@rollup/rollup-win32-x64-msvc': 4.41.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 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - rxjs@7.8.2: dependencies: tslib: 2.8.1 - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - sass@1.70.0: dependencies: chokidar: 3.6.0 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: {} @@ -7250,34 +5454,6 @@ snapshots: set-blocking@2.0.0: {} - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - shell-quote@1.8.3: {} side-channel-list@1.0.0: @@ -7325,16 +5501,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: @@ -7361,67 +5537,12 @@ snapshots: stringify-package: 1.0.1 yargs: 16.2.0 - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string.prototype.includes@2.0.1: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - - string.prototype.matchall@4.0.12: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - regexp.prototype.flags: 1.5.4 - set-function-name: 2.0.2 - side-channel: 1.1.0 - - string.prototype.repeat@1.0.0: - dependencies: - define-properties: 1.2.1 - es-abstract: 1.24.0 - - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -7447,13 +5568,11 @@ snapshots: dependencies: min-indent: 1.0.1 - strip-json-comments@3.1.1: {} - - style-to-js@1.1.16: + style-to-js@1.1.17: dependencies: - style-to-object: 1.0.8 + style-to-object: 1.0.9 - style-to-object@1.0.8: + style-to-object@1.0.9: dependencies: inline-style-parser: 0.2.4 @@ -7473,10 +5592,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.8: - dependencies: - '@pkgr/core': 0.2.7 - tabbable@6.2.0: {} terser@5.37.0: @@ -7590,10 +5705,10 @@ snapshots: tiny-invariant@1.3.3: {} - tinyglobby@0.2.14: + tinyglobby@0.2.15: dependencies: - fdir: 6.4.5(picomatch@4.0.2) - picomatch: 4.0.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 to-regex-range@5.0.1: dependencies: @@ -7607,97 +5722,28 @@ snapshots: trough@2.2.0: {} - ts-api-utils@2.1.0(typescript@5.8.3): - dependencies: - typescript: 5.8.3 - - tsconfig-paths@3.15.0: - dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 - tslib@2.8.1: {} - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - type-fest@0.18.1: {} type-fest@0.6.0: {} type-fest@0.8.1: {} - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 + type-fest@4.41.0: {} typedarray@0.0.6: {} - typesafe-i18n@5.26.2(typescript@5.8.3): - dependencies: - typescript: 5.8.3 - - typescript-eslint-language-service@5.0.5(@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0)(typescript@5.8.3): - dependencies: - '@typescript-eslint/parser': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - eslint: 9.28.0 - typescript: 5.8.3 - - typescript-eslint@8.33.1(eslint@9.28.0)(typescript@5.8.3): + typesafe-i18n@5.26.2(typescript@5.9.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.33.1(@typescript-eslint/parser@8.33.1(eslint@9.28.0)(typescript@5.8.3))(eslint@9.28.0)(typescript@5.8.3) - '@typescript-eslint/parser': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - '@typescript-eslint/utils': 8.33.1(eslint@9.28.0)(typescript@5.8.3) - eslint: 9.28.0 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color + typescript: 5.9.2 - typescript@5.8.3: {} + typescript@5.9.2: {} uglify-js@3.19.3: optional: true - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - - undici-types@6.21.0: {} + undici-types@7.10.0: {} unified@11.0.5: dependencies: @@ -7732,35 +5778,31 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 - update-browserslist-db@1.1.3(browserslist@4.25.0): + update-browserslist-db@1.1.3(browserslist@4.25.4): dependencies: - browserslist: 4.25.0 + browserslist: 4.25.4 escalade: 3.2.0 picocolors: 1.1.1 - uri-js@4.4.1: - dependencies: - punycode: 2.3.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: {} @@ -7776,7 +5818,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 @@ -7784,9 +5826,9 @@ snapshots: vfile@6.0.3: dependencies: '@types/unist': 3.0.3 - vfile-message: 4.0.2 + vfile-message: 4.0.3 - victory-vendor@36.9.2: + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.1 '@types/d3-ease': 3.0.2 @@ -7803,84 +5845,30 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-eslint@1.8.1(eslint@9.28.0)(vite@6.3.5(@types/node@22.15.29)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)): - dependencies: - '@rollup/pluginutils': 4.2.1 - '@types/eslint': 8.56.12 - eslint: 9.28.0 - rollup: 2.79.2 - vite: 6.3.5(@types/node@22.15.29)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) - - vite-plugin-package-version@1.1.0(vite@6.3.5(@types/node@22.15.29)(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: 6.3.5(@types/node@22.15.29)(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@6.3.5(@types/node@22.15.29)(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.5 - fdir: 6.4.5(picomatch@4.0.2) - picomatch: 4.0.2 - postcss: 8.5.4 - rollup: 4.41.1 - tinyglobby: 0.2.14 + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.50.1 + tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 22.15.29 + '@types/node': 24.3.1 fsevents: 2.3.3 + jiti: 2.4.2 sass: 1.70.0 terser: 5.37.0 yaml: 2.6.1 web-namespaces@2.0.1: {} - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.0 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.19 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - which-module@2.0.1: {} - which-typed-array@1.1.19: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - wordwrap@1.0.0: {} wrap-ansi@6.2.0: @@ -7955,12 +5943,13 @@ snapshots: yocto-queue@0.1.0: {} - zod@3.25.51: {} + zod@3.25.76: {} - zustand@5.0.5(@types/react@18.3.23)(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 - 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.scss b/web/src/components/App/App.scss deleted file mode 100644 index 2b4db9f378..0000000000 --- a/web/src/components/App/App.scss +++ /dev/null @@ -1,5 +0,0 @@ -#app { - height: inherit; - max-height: inherit; - position: relative; -} diff --git a/web/src/components/App/App.tsx b/web/src/components/App/App.tsx index 088b0982df..1f05e4b305 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -1,7 +1,6 @@ import 'react-loading-skeleton/dist/skeleton.css'; -import './App.scss'; -import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom'; +import { Navigate, Route, BrowserRouter as Router, Routes } from 'react-router-dom'; import { AclRoutes } from '../../pages/acl/AclRoutes'; import { ActivityLogPage } from '../../pages/activity-log/ActivityLogPage'; @@ -25,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'; @@ -205,6 +205,7 @@ const App = () => { + diff --git a/web/src/components/AppLoader.tsx b/web/src/components/AppLoader.tsx index 1ed467f88f..dd78829b13 100644 --- a/web/src/components/AppLoader.tsx +++ b/web/src/components/AppLoader.tsx @@ -5,11 +5,13 @@ 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'; import useApi from '../shared/hooks/useApi'; -import { useToaster } from '../shared/hooks/useToaster'; import { QueryKeys } from '../shared/queries'; /** @@ -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, @@ -44,13 +56,13 @@ export const AppLoader = () => { retry: false, }); + // biome-ignore lint/correctness/useExhaustiveDependencies: sideEffect useEffect(() => { if (meFetchError && currentUser) { if (currentUser) { resetAuthState(); } } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [meFetchError]); useEffect(() => { @@ -67,6 +79,7 @@ export const AppLoader = () => { enabled: !isUndefined(currentUser), }); + // biome-ignore lint/correctness/useExhaustiveDependencies: sideEffect useEffect(() => { if (appInfoError) { toaster.error(LL.messages.errorVersion()); @@ -135,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/I18nProvider.tsx b/web/src/components/I18nProvider.tsx index 90d249f52b..23b54ed280 100644 --- a/web/src/components/I18nProvider.tsx +++ b/web/src/components/I18nProvider.tsx @@ -1,7 +1,7 @@ import { enUS as datePickerLocaleEnUS } from 'date-fns/locale/en-US'; import { ko as datePickerLocaleKO } from 'date-fns/locale/ko'; import { pl as datePickerLocalePL } from 'date-fns/locale/pl'; -import { PropsWithChildren, useEffect, useState } from 'react'; +import { type PropsWithChildren, useEffect, useState } from 'react'; import { registerLocale, setDefaultLocale } from 'react-datepicker'; import { navigatorDetector } from 'typesafe-i18n/detectors'; import { shallow } from 'zustand/shallow'; @@ -18,6 +18,7 @@ export const I18nProvider = ({ children }: PropsWithChildren) => { const detectedLocale = detectLocale(navigatorDetector); const [localeLoaded, setLocaleLoaded] = useState(false); + // biome-ignore lint/correctness/useExhaustiveDependencies: migration useEffect(() => { const lang = detectedLocale ?? baseLocale; loadLocale(lang); diff --git a/web/src/components/Navigation/Navigation.tsx b/web/src/components/Navigation/Navigation.tsx index 330e2c0e44..4f67f60b0e 100644 --- a/web/src/components/Navigation/Navigation.tsx +++ b/web/src/components/Navigation/Navigation.tsx @@ -23,7 +23,7 @@ import { useAuthStore } from '../../shared/hooks/store/useAuthStore'; import { useUserProfileStore } from '../../shared/hooks/store/useUserProfileStore'; import useApi from '../../shared/hooks/useApi'; import { QueryKeys } from '../../shared/queries'; -import { User } from '../../shared/types'; +import type { User } from '../../shared/types'; import { invalidateMultipleQueries } from '../../shared/utils/invalidateMultipleQueries'; import { DevicePageNavigationIcon } from './components/DevicesPageNavigationIcon'; import { NavigationActivityLogPageIcon } from './components/icons/NavigationActivityLogPageIcon'; @@ -31,7 +31,7 @@ import { NavigationDesktop } from './components/NavigationDesktop/NavigationDesk import { NavigationMobile } from './components/NavigationMobile/NavigationMobile'; import { navigationExcludedRoutes } from './config'; import { useNavigationStore } from './hooks/useNavigationStore'; -import { NavigationItem, NavigationItems } from './types'; +import type { NavigationItem, NavigationItems } from './types'; export const Navigation = () => { const { pathname } = useLocation(); diff --git a/web/src/components/Navigation/components/ApplicationVersion/ApplicationVersion.tsx b/web/src/components/Navigation/components/ApplicationVersion/ApplicationVersion.tsx index a6c88d7b6c..462f6f6689 100644 --- a/web/src/components/Navigation/components/ApplicationVersion/ApplicationVersion.tsx +++ b/web/src/components/Navigation/components/ApplicationVersion/ApplicationVersion.tsx @@ -14,21 +14,19 @@ export const ApplicationVersion = ({ isOpen }: Props) => { return ( ); 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/NavigationBar/NavigationBar.tsx b/web/src/components/Navigation/components/NavigationBar/NavigationBar.tsx index 58d4c211ca..2ecee40ac2 100644 --- a/web/src/components/Navigation/components/NavigationBar/NavigationBar.tsx +++ b/web/src/components/Navigation/components/NavigationBar/NavigationBar.tsx @@ -8,7 +8,7 @@ import { useI18nContext } from '../../../../i18n/i18n-react'; import SvgDefguardNavLogoCollapsed from '../../../../shared/components/svg/DefguardNavLogoCollapsed'; import SvgIconNavLogout from '../../../../shared/components/svg/IconNavLogout'; import { useAppStore } from '../../../../shared/hooks/store/useAppStore'; -import { NavigationItems } from '../../types'; +import type { NavigationItems } from '../../types'; import { ApplicationVersion } from '../ApplicationVersion/ApplicationVersion'; import { NavigationLink } from '../NavigationLink/NavigationLink'; diff --git a/web/src/components/Navigation/components/NavigationDesktop/NavigationCollapse/NavigationCollapse.tsx b/web/src/components/Navigation/components/NavigationDesktop/NavigationCollapse/NavigationCollapse.tsx index 2348357aa4..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, 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/components/Navigation/components/NavigationDesktop/NavigationDesktop.tsx b/web/src/components/Navigation/components/NavigationDesktop/NavigationDesktop.tsx index 657b30ab60..2509cd77d0 100644 --- a/web/src/components/Navigation/components/NavigationDesktop/NavigationDesktop.tsx +++ b/web/src/components/Navigation/components/NavigationDesktop/NavigationDesktop.tsx @@ -1,5 +1,5 @@ import { useNavigationStore } from '../../hooks/useNavigationStore'; -import { NavigationItems } from '../../types'; +import type { NavigationItems } from '../../types'; import { NavigationBar } from '../NavigationBar/NavigationBar'; import { NavigationCollapse } from './NavigationCollapse/NavigationCollapse'; diff --git a/web/src/components/Navigation/components/NavigationLink/NavigationLink.tsx b/web/src/components/Navigation/components/NavigationLink/NavigationLink.tsx index 70ac91bad7..54f7e7189c 100644 --- a/web/src/components/Navigation/components/NavigationLink/NavigationLink.tsx +++ b/web/src/components/Navigation/components/NavigationLink/NavigationLink.tsx @@ -9,7 +9,7 @@ import { useUpgradeLicenseModal } from '../../../../shared/components/Layout/Upg import { UpgradeLicenseModalVariant } from '../../../../shared/components/Layout/UpgradeLicenseModal/types'; import { useAppStore } from '../../../../shared/hooks/store/useAppStore'; import { useNavigationStore } from '../../hooks/useNavigationStore'; -import { NavigationItem } from '../../types'; +import type { NavigationItem } from '../../types'; interface NavigationLinkProps { item: NavigationItem; diff --git a/web/src/components/Navigation/components/NavigationMobile/MobileNavModal/MobileNavModal.tsx b/web/src/components/Navigation/components/NavigationMobile/MobileNavModal/MobileNavModal.tsx index 32f0379117..877f27b8c9 100644 --- a/web/src/components/Navigation/components/NavigationMobile/MobileNavModal/MobileNavModal.tsx +++ b/web/src/components/Navigation/components/NavigationMobile/MobileNavModal/MobileNavModal.tsx @@ -3,7 +3,7 @@ import './style.scss'; import SvgIconHamburgerClose from '../../../../../shared/components/svg/IconHamburgerClose'; import { Modal } from '../../../../../shared/defguard-ui/components/Layout/modals/Modal/Modal'; import { useNavigationStore } from '../../../hooks/useNavigationStore'; -import { NavigationItems } from '../../../types'; +import type { NavigationItems } from '../../../types'; import { NavigationBar } from '../../NavigationBar/NavigationBar'; interface Props { diff --git a/web/src/components/Navigation/components/NavigationMobile/NavigationMobile.tsx b/web/src/components/Navigation/components/NavigationMobile/NavigationMobile.tsx index ad59c84f2a..07bd0e0f96 100644 --- a/web/src/components/Navigation/components/NavigationMobile/NavigationMobile.tsx +++ b/web/src/components/Navigation/components/NavigationMobile/NavigationMobile.tsx @@ -7,7 +7,7 @@ import { useI18nContext } from '../../../../i18n/i18n-react'; import SvgDefguardNavLogoCollapsed from '../../../../shared/components/svg/DefguardNavLogoCollapsed'; import SvgIconNavHamburger from '../../../../shared/components/svg/IconNavHamburger'; import { useNavigationStore } from '../../hooks/useNavigationStore'; -import { NavigationItems } from '../../types'; +import type { NavigationItems } from '../../types'; import { MobileNavModal } from './MobileNavModal/MobileNavModal'; type Props = { diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 41a0223504..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', @@ -263,6 +272,18 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do cancel: 'Cancel', }, }, + disableMfa: { + title: 'Disable MFA', + message: 'Do you want to disable MFA for user {username: string}?', + messages: { + success: 'MFA for user {username: string} has been disabled', + error: 'Failed to disable MFA for user {username: string}', + }, + controls: { + submit: 'Disable MFA', + cancel: 'Cancel', + }, + }, startEnrollment: { title: 'Start enrollment', desktopTitle: 'Desktop activation', @@ -272,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', @@ -430,7 +457,7 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do controls: { submit: 'Delete account', }, - message: 'Do you want to delete {username: string} account permanently ?', + message: 'Do you want to delete {username: string} account permanently?', messages: { success: '{username: string} deleted.', }, @@ -510,6 +537,9 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do }, form: { submit: 'Add user', + error: { + emailReserved: 'Email already taken', + }, fields: { username: { placeholder: 'login', @@ -611,17 +641,40 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do }, steps: { setupMethod: { - remote: { - title: 'Configure Desktop Client', - subTitle: - 'A breeze to set up with just a single token. Download the client and enjoy straightforward security.', - link: 'Download defguard Client', - }, - manual: { - title: 'Manual WireGuard Client', - subTitle: - 'For advanced users, get a unique config via download or QR code. Download the client and take control of your VPN setup.', - link: 'Download WireGuard Client', + title: 'Choose Your Connection Method', + message: + "You can add a device using this wizard. To proceed, you'll need to install the defguard Client on the device you're adding. You can also use any standard WireGuard® client, but for the best experience and ease of setup, we recommend using our native defguard Client.", + methods: { + client: { + title: 'Remote Device Activation', + description: + 'Use the Defguard Client to set up your device. Easily configure it with a single token or by scanning a QR code.', + }, + wg: { + title: 'Manual WireGuard Client', + description: + 'For advanced users, get a unique config via download or QR code. Download any WireGuard® client and take control of your VPN setup.', + }, + }, + }, + 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: + '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', + labels: { + mergedToken: 'Defguard Instance Token (new)', + token: 'Authentication Token', + url: 'URL', }, }, configDevice: { @@ -658,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.

`, @@ -965,6 +1018,7 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do startEnrollment: 'Start enrollment', activateDesktop: 'Configure Desktop Client', resetPassword: 'Reset password', + disableMfa: 'Disable MFA', }, }, }, @@ -1059,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: { @@ -1099,6 +1154,14 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do contact: 'by contacting:', }, }, + locationMfaModeSelect: { + label: 'MFA Requirement', + options: { + disabled: 'Do not enforce MFA', + internal: 'Internal MFA', + external: 'External MFA', + }, + }, }, settingsPage: { title: 'Settings', @@ -1362,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: @@ -1373,7 +1441,7 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do modulesVisibility: { header: 'Modules Visibility', helper: `

- If your not using some modules you can disable their visibility. + Hide unused modules.

Read more in documentation. @@ -1944,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: @@ -1952,6 +2022,23 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do 'By default, all users will be allowed to connect to this location. If you want to restrict access to this location to a specific group, please select it below.', aclFeatureDisabled: "ACL functionality is an enterprise feature and you've exceeded the user, device or network limits to use it. In order to use this feature, purchase an enterprise license or upgrade your existing one.", + peerDisconnectThreshold: + 'Clients authorized with MFA will be disconnected from the location once there has been no network activity detected between them and the VPN gateway for a length of time configured below.', + locationMfaMode: { + description: 'Choose how MFA is enforced when connecting to this location:', + internal: + "Internal MFA - MFA is enforced using Defguard's built-in MFA (e.g. TOTP, WebAuthn) with internal identity", + external: + 'External MFA - If configured (see [OpenID settings](settings)) this option uses external identity provider for MFA', + }, + }, + sections: { + accessControl: { + header: 'Access Control & Firewall', + }, + mfa: { + header: 'Multi-Factor Authentication', + }, }, messages: { networkModified: 'Location modified.', @@ -1965,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', @@ -1980,14 +2067,11 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do label: 'Allowed groups', placeholder: 'All groups', }, - mfa_enabled: { - label: 'Require MFA for this Location', - }, keepalive_interval: { label: 'Keepalive interval [seconds]', }, peer_disconnect_threshold: { - label: 'Peer disconnect threshold [seconds]', + label: 'Client disconnect threshold [seconds]', }, acl_enabled: { label: 'Enable ACL for this location', @@ -1995,6 +2079,9 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do acl_default_allow: { label: 'Default ACL policy', }, + location_mfa_mode: { + label: 'MFA requirement', + }, }, controls: { submit: 'Save changes', @@ -2569,9 +2656,11 @@ This alias is currently in use by the following rule(s) and cannot be deleted. T date: 'Date', user: 'User', ip: 'IP', + location: 'Location', event: 'Event', module: 'Module', device: 'Device', + description: 'Description', }, noData: { data: 'No activities present', @@ -2590,8 +2679,10 @@ This alias is currently in use by the following rule(s) and cannot be deleted. T user_added: 'User added', user_removed: 'User removed', user_modified: 'User modified', + user_groups_modified: 'User groups modified', mfa_enabled: 'MFA enabled', mfa_disabled: 'MFA disabled', + user_mfa_disabled: 'User MFA disabled', mfa_totp_enabled: 'MFA TOTP enabled', mfa_totp_disabled: 'MFA TOTP disabled', mfa_email_enabled: 'MFA email enabled', @@ -2612,12 +2703,49 @@ This alias is currently in use by the following rule(s) and cannot be deleted. T vpn_client_connected_mfa: 'VPN client connected to MFA location', vpn_client_disconnected_mfa: 'VPN client disconnected from MFA location', vpn_client_mfa_failed: 'VPN client failed MFA authentication', + enrollment_token_added: 'Enrollment token added', enrollment_started: 'Enrollment started', enrollment_device_added: 'Device added', enrollment_completed: 'Enrollment completed', password_reset_requested: 'Password reset requested', password_reset_started: 'Password reset started', password_reset_completed: 'Password reset completed', + vpn_location_added: 'VPN location added', + vpn_location_removed: 'VPN location removed', + vpn_location_modified: 'VPN location modified', + api_token_added: 'API token added', + api_token_removed: 'API token removed', + api_token_renamed: 'API token renamed', + open_id_app_added: 'OpenID app added', + open_id_app_removed: 'OpenID app removed', + open_id_app_modified: 'OpenID app modified', + open_id_app_state_changed: 'OpenID app state changed', + open_id_provider_removed: 'OpenID provider removed', + open_id_provider_modified: 'OpenID provider modified', + settings_updated: 'Settings updated', + settings_updated_partial: 'Settings partially updated', + settings_default_branding_restored: 'Default branding restored', + groups_bulk_assigned: 'Groups bulk assigned', + group_added: 'Group added', + group_modified: 'Group modified', + group_removed: 'Group removed', + group_member_added: 'Group member added', + group_member_removed: 'Group member removed', + group_members_modified: 'Group members modified', + web_hook_added: 'Webhook added', + web_hook_modified: 'Webhook modified', + web_hook_removed: 'Webhook removed', + web_hook_state_changed: 'Webhook state changed', + authentication_key_added: 'Authentication key added', + authentication_key_removed: 'Authentication key removed', + authentication_key_renamed: 'Authentication key renamed', + password_changed: 'Password changed', + password_changed_by_admin: 'Password changed by admin', + password_reset: 'Password reset', + client_configuration_token_added: 'Client configuration token added', + user_snat_binding_added: 'User SNAT binding added', + user_snat_binding_modified: 'User SNAT binding modified', + user_snat_binding_removed: 'User SNAT binding removed', }, activityLogModule: { defguard: 'Defguard', diff --git a/web/src/i18n/formatters.ts b/web/src/i18n/formatters.ts index f522571096..6f3d0997ea 100644 --- a/web/src/i18n/formatters.ts +++ b/web/src/i18n/formatters.ts @@ -4,7 +4,7 @@ import type { Formatters, Locales } from './i18n-types'; export const initFormatters: FormattersInitializer = ( //@ts-ignore - locale: Locales + locale: Locales, ) => { const formatters: Formatters = { // add your formatter functions here diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 6fae5e04eb..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: { /** @@ -672,6 +696,39 @@ type RootTranslation = { cancel: string } } + disableMfa: { + /** + * D​i​s​a​b​l​e​ ​M​F​A + */ + title: string + /** + * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​i​s​a​b​l​e​ ​M​F​A​ ​f​o​r​ ​u​s​e​r​ ​{​u​s​e​r​n​a​m​e​}​? + * @param {string} username + */ + message: RequiredParams<'username'> + messages: { + /** + * M​F​A​ ​f​o​r​ ​u​s​e​r​ ​{​u​s​e​r​n​a​m​e​}​ ​h​a​s​ ​b​e​e​n​ ​d​i​s​a​b​l​e​d + * @param {string} username + */ + success: RequiredParams<'username'> + /** + * F​a​i​l​e​d​ ​t​o​ ​d​i​s​a​b​l​e​ ​M​F​A​ ​f​o​r​ ​u​s​e​r​ ​{​u​s​e​r​n​a​m​e​} + * @param {string} username + */ + error: RequiredParams<'username'> + } + controls: { + /** + * D​i​s​a​b​l​e​ ​M​F​A + */ + submit: string + /** + * C​a​n​c​e​l + */ + cancel: string + } + } startEnrollment: { /** * S​t​a​r​t​ ​e​n​r​o​l​l​m​e​n​t @@ -699,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: { /** @@ -1020,7 +1087,7 @@ type RootTranslation = { submit: string } /** - * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​e​l​e​t​e​ ​{​u​s​e​r​n​a​m​e​}​ ​a​c​c​o​u​n​t​ ​p​e​r​m​a​n​e​n​t​l​y​ ​? + * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​e​l​e​t​e​ ​{​u​s​e​r​n​a​m​e​}​ ​a​c​c​o​u​n​t​ ​p​e​r​m​a​n​e​n​t​l​y​? * @param {string} username */ message: RequiredParams<'username'> @@ -1196,6 +1263,12 @@ type RootTranslation = { * A​d​d​ ​u​s​e​r */ submit: string + error: { + /** + * E​m​a​i​l​ ​a​l​r​e​a​d​y​ ​t​a​k​e​n + */ + emailReserved: string + } fields: { username: { /** @@ -1427,33 +1500,83 @@ type RootTranslation = { } steps: { setupMethod: { - remote: { - /** - * C​o​n​f​i​g​u​r​e​ ​D​e​s​k​t​o​p​ ​C​l​i​e​n​t - */ - title: string - /** - * A​ ​b​r​e​e​z​e​ ​t​o​ ​s​e​t​ ​u​p​ ​w​i​t​h​ ​j​u​s​t​ ​a​ ​s​i​n​g​l​e​ ​t​o​k​e​n​.​ ​D​o​w​n​l​o​a​d​ ​t​h​e​ ​c​l​i​e​n​t​ ​a​n​d​ ​e​n​j​o​y​ ​s​t​r​a​i​g​h​t​f​o​r​w​a​r​d​ ​s​e​c​u​r​i​t​y​. - */ - subTitle: string - /** - * D​o​w​n​l​o​a​d​ ​d​e​f​g​u​a​r​d​ ​C​l​i​e​n​t - */ - link: string + /** + * C​h​o​o​s​e​ ​Y​o​u​r​ ​C​o​n​n​e​c​t​i​o​n​ ​M​e​t​h​o​d + */ + title: string + /** + * Y​o​u​ ​c​a​n​ ​a​d​d​ ​a​ ​d​e​v​i​c​e​ ​u​s​i​n​g​ ​t​h​i​s​ ​w​i​z​a​r​d​.​ ​T​o​ ​p​r​o​c​e​e​d​,​ ​y​o​u​'​l​l​ ​n​e​e​d​ ​t​o​ ​i​n​s​t​a​l​l​ ​t​h​e​ ​d​e​f​g​u​a​r​d​ ​C​l​i​e​n​t​ ​o​n​ ​t​h​e​ ​d​e​v​i​c​e​ ​y​o​u​'​r​e​ ​a​d​d​i​n​g​.​ ​Y​o​u​ ​c​a​n​ ​a​l​s​o​ ​u​s​e​ ​a​n​y​ ​s​t​a​n​d​a​r​d​ ​W​i​r​e​G​u​a​r​d​®​ ​c​l​i​e​n​t​,​ ​b​u​t​ ​f​o​r​ ​t​h​e​ ​b​e​s​t​ ​e​x​p​e​r​i​e​n​c​e​ ​a​n​d​ ​e​a​s​e​ ​o​f​ ​s​e​t​u​p​,​ ​w​e​ ​r​e​c​o​m​m​e​n​d​ ​u​s​i​n​g​ ​o​u​r​ ​n​a​t​i​v​e​ ​d​e​f​g​u​a​r​d​ ​C​l​i​e​n​t​. + */ + message: string + methods: { + client: { + /** + * R​e​m​o​t​e​ ​D​e​v​i​c​e​ ​A​c​t​i​v​a​t​i​o​n + */ + title: string + /** + * U​s​e​ ​t​h​e​ ​D​e​f​g​u​a​r​d​ ​C​l​i​e​n​t​ ​t​o​ ​s​e​t​ ​u​p​ ​y​o​u​r​ ​d​e​v​i​c​e​.​ ​E​a​s​i​l​y​ ​c​o​n​f​i​g​u​r​e​ ​i​t​ ​w​i​t​h​ ​a​ ​s​i​n​g​l​e​ ​t​o​k​e​n​ ​o​r​ ​b​y​ ​s​c​a​n​n​i​n​g​ ​a​ ​Q​R​ ​c​o​d​e​. + */ + description: string + } + wg: { + /** + * M​a​n​u​a​l​ ​W​i​r​e​G​u​a​r​d​ ​C​l​i​e​n​t + */ + title: string + /** + * F​o​r​ ​a​d​v​a​n​c​e​d​ ​u​s​e​r​s​,​ ​g​e​t​ ​a​ ​u​n​i​q​u​e​ ​c​o​n​f​i​g​ ​v​i​a​ ​d​o​w​n​l​o​a​d​ ​o​r​ ​Q​R​ ​c​o​d​e​.​ ​D​o​w​n​l​o​a​d​ ​a​n​y​ ​W​i​r​e​G​u​a​r​d​®​ ​c​l​i​e​n​t​ ​a​n​d​ ​t​a​k​e​ ​c​o​n​t​r​o​l​ ​o​f​ ​y​o​u​r​ ​V​P​N​ ​s​e​t​u​p​. + */ + description: string + } } - manual: { + } + client: { + /** + * C​l​i​e​n​t​ ​A​c​t​i​v​a​t​i​o​n + */ + title: 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​ ​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 + */ + desktopDownload: string + /** + * T​o​k​e​n​ ​c​o​p​i​e​d​ ​t​o​ ​c​l​i​p​b​o​a​r​d + */ + tokenCopy: string + /** + * F​a​i​l​e​d​ ​t​o​ ​p​r​e​p​a​r​e​ ​c​l​i​e​n​t​ ​s​e​t​u​p + */ + tokenFailure: string + labels: { /** - * M​a​n​u​a​l​ ​W​i​r​e​G​u​a​r​d​ ​C​l​i​e​n​t + * D​e​f​g​u​a​r​d​ ​I​n​s​t​a​n​c​e​ ​T​o​k​e​n​ ​(​n​e​w​) */ - title: string + mergedToken: string /** - * F​o​r​ ​a​d​v​a​n​c​e​d​ ​u​s​e​r​s​,​ ​g​e​t​ ​a​ ​u​n​i​q​u​e​ ​c​o​n​f​i​g​ ​v​i​a​ ​d​o​w​n​l​o​a​d​ ​o​r​ ​Q​R​ ​c​o​d​e​.​ ​D​o​w​n​l​o​a​d​ ​t​h​e​ ​c​l​i​e​n​t​ ​a​n​d​ ​t​a​k​e​ ​c​o​n​t​r​o​l​ ​o​f​ ​y​o​u​r​ ​V​P​N​ ​s​e​t​u​p​. + * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​T​o​k​e​n */ - subTitle: string + token: string /** - * D​o​w​n​l​o​a​d​ ​W​i​r​e​G​u​a​r​d​ ​C​l​i​e​n​t + * U​R​L */ - link: string + url: string } } configDevice: { @@ -1520,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​>​ @@ -2286,6 +2409,10 @@ type RootTranslation = { * R​e​s​e​t​ ​p​a​s​s​w​o​r​d */ resetPassword: string + /** + * D​i​s​a​b​l​e​ ​M​F​A + */ + disableMfa: string } } } @@ -2608,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 @@ -2700,6 +2831,26 @@ type RootTranslation = { contact: string } } + locationMfaModeSelect: { + /** + * M​F​A​ ​R​e​q​u​i​r​e​m​e​n​t + */ + label: string + options: { + /** + * D​o​ ​n​o​t​ ​e​n​f​o​r​c​e​ ​M​F​A + */ + disabled: string + /** + * I​n​t​e​r​n​a​l​ ​M​F​A + */ + internal: string + /** + * E​x​t​e​r​n​a​l​ ​M​F​A + */ + external: string + } + } } settingsPage: { /** @@ -3343,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 @@ -3363,7 +3524,7 @@ type RootTranslation = { header: string /** * <​p​>​ - ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​I​f​ ​y​o​u​r​ ​n​o​t​ ​u​s​i​n​g​ ​s​o​m​e​ ​m​o​d​u​l​e​s​ ​y​o​u​ ​c​a​n​ ​d​i​s​a​b​l​e​ ​t​h​e​i​r​ ​v​i​s​i​b​i​l​i​t​y​.​ + ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​H​i​d​e​ ​u​n​u​s​e​d​ ​m​o​d​u​l​e​s​.​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​/​p​>​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​<​a​ ​h​r​e​f​=​"​{​d​o​c​u​m​e​n​t​a​t​i​o​n​L​i​n​k​}​"​ ​t​a​r​g​e​t​=​"​_​b​l​a​n​k​"​>​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​ ​R​e​a​d​ ​m​o​r​e​ ​i​n​ ​d​o​c​u​m​e​n​t​a​t​i​o​n​.​ @@ -4669,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 */ @@ -4689,6 +4854,38 @@ type RootTranslation = { * A​C​L​ ​f​u​n​c​t​i​o​n​a​l​i​t​y​ ​i​s​ ​a​n​ ​e​n​t​e​r​p​r​i​s​e​ ​f​e​a​t​u​r​e​ ​a​n​d​ ​y​o​u​'​v​e​ ​e​x​c​e​e​d​e​d​ ​t​h​e​ ​u​s​e​r​,​ ​d​e​v​i​c​e​ ​o​r​ ​n​e​t​w​o​r​k​ ​l​i​m​i​t​s​ ​t​o​ ​u​s​e​ ​i​t​.​ ​I​n​ ​o​r​d​e​r​ ​t​o​ ​u​s​e​ ​t​h​i​s​ ​f​e​a​t​u​r​e​,​ ​p​u​r​c​h​a​s​e​ ​a​n​ ​e​n​t​e​r​p​r​i​s​e​ ​l​i​c​e​n​s​e​ ​o​r​ ​u​p​g​r​a​d​e​ ​y​o​u​r​ ​e​x​i​s​t​i​n​g​ ​o​n​e​. */ aclFeatureDisabled: string + /** + * C​l​i​e​n​t​s​ ​a​u​t​h​o​r​i​z​e​d​ ​w​i​t​h​ ​M​F​A​ ​w​i​l​l​ ​b​e​ ​d​i​s​c​o​n​n​e​c​t​e​d​ ​f​r​o​m​ ​t​h​e​ ​l​o​c​a​t​i​o​n​ ​o​n​c​e​ ​t​h​e​r​e​ ​h​a​s​ ​b​e​e​n​ ​n​o​ ​n​e​t​w​o​r​k​ ​a​c​t​i​v​i​t​y​ ​d​e​t​e​c​t​e​d​ ​b​e​t​w​e​e​n​ ​t​h​e​m​ ​a​n​d​ ​t​h​e​ ​V​P​N​ ​g​a​t​e​w​a​y​ ​f​o​r​ ​a​ ​l​e​n​g​t​h​ ​o​f​ ​t​i​m​e​ ​c​o​n​f​i​g​u​r​e​d​ ​b​e​l​o​w​. + */ + peerDisconnectThreshold: string + locationMfaMode: { + /** + * C​h​o​o​s​e​ ​h​o​w​ ​M​F​A​ ​i​s​ ​e​n​f​o​r​c​e​d​ ​w​h​e​n​ ​c​o​n​n​e​c​t​i​n​g​ ​t​o​ ​t​h​i​s​ ​l​o​c​a​t​i​o​n​: + */ + description: string + /** + * I​n​t​e​r​n​a​l​ ​M​F​A​ ​-​ ​M​F​A​ ​i​s​ ​e​n​f​o​r​c​e​d​ ​u​s​i​n​g​ ​D​e​f​g​u​a​r​d​'​s​ ​b​u​i​l​t​-​i​n​ ​M​F​A​ ​(​e​.​g​.​ ​T​O​T​P​,​ ​W​e​b​A​u​t​h​n​)​ ​w​i​t​h​ ​i​n​t​e​r​n​a​l​ ​i​d​e​n​t​i​t​y + */ + internal: string + /** + * E​x​t​e​r​n​a​l​ ​M​F​A​ ​-​ ​I​f​ ​c​o​n​f​i​g​u​r​e​d​ ​(​s​e​e​ ​[​O​p​e​n​I​D​ ​s​e​t​t​i​n​g​s​]​(​s​e​t​t​i​n​g​s​)​)​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​u​s​e​s​ ​e​x​t​e​r​n​a​l​ ​i​d​e​n​t​i​t​y​ ​p​r​o​v​i​d​e​r​ ​f​o​r​ ​M​F​A + */ + external: string + } + } + sections: { + accessControl: { + /** + * A​c​c​e​s​s​ ​C​o​n​t​r​o​l​ ​&​ ​F​i​r​e​w​a​l​l + */ + header: string + } + mfa: { + /** + * M​u​l​t​i​-​F​a​c​t​o​r​ ​A​u​t​h​e​n​t​i​c​a​t​i​o​n + */ + header: string + } } messages: { /** @@ -4715,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 } @@ -4747,12 +4944,6 @@ type RootTranslation = { */ placeholder: string } - mfa_enabled: { - /** - * R​e​q​u​i​r​e​ ​M​F​A​ ​f​o​r​ ​t​h​i​s​ ​L​o​c​a​t​i​o​n - */ - label: string - } keepalive_interval: { /** * K​e​e​p​a​l​i​v​e​ ​i​n​t​e​r​v​a​l​ ​[​s​e​c​o​n​d​s​] @@ -4761,7 +4952,7 @@ type RootTranslation = { } peer_disconnect_threshold: { /** - * P​e​e​r​ ​d​i​s​c​o​n​n​e​c​t​ ​t​h​r​e​s​h​o​l​d​ ​[​s​e​c​o​n​d​s​] + * C​l​i​e​n​t​ ​d​i​s​c​o​n​n​e​c​t​ ​t​h​r​e​s​h​o​l​d​ ​[​s​e​c​o​n​d​s​] */ label: string } @@ -4777,6 +4968,12 @@ type RootTranslation = { */ label: string } + location_mfa_mode: { + /** + * M​F​A​ ​r​e​q​u​i​r​e​m​e​n​t + */ + label: string + } } controls: { /** @@ -6163,6 +6360,10 @@ type RootTranslation = { * I​P */ ip: string + /** + * L​o​c​a​t​i​o​n + */ + location: string /** * E​v​e​n​t */ @@ -6175,6 +6376,10 @@ type RootTranslation = { * D​e​v​i​c​e */ device: string + /** + * D​e​s​c​r​i​p​t​i​o​n + */ + description: string } noData: { /** @@ -6226,6 +6431,10 @@ type RootTranslation = { * U​s​e​r​ ​m​o​d​i​f​i​e​d */ user_modified: string + /** + * U​s​e​r​ ​g​r​o​u​p​s​ ​m​o​d​i​f​i​e​d + */ + user_groups_modified: string /** * M​F​A​ ​e​n​a​b​l​e​d */ @@ -6234,6 +6443,10 @@ type RootTranslation = { * M​F​A​ ​d​i​s​a​b​l​e​d */ mfa_disabled: string + /** + * U​s​e​r​ ​M​F​A​ ​d​i​s​a​b​l​e​d + */ + user_mfa_disabled: string /** * M​F​A​ ​T​O​T​P​ ​e​n​a​b​l​e​d */ @@ -6314,6 +6527,10 @@ type RootTranslation = { * V​P​N​ ​c​l​i​e​n​t​ ​f​a​i​l​e​d​ ​M​F​A​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n */ vpn_client_mfa_failed: string + /** + * E​n​r​o​l​l​m​e​n​t​ ​t​o​k​e​n​ ​a​d​d​e​d + */ + enrollment_token_added: string /** * E​n​r​o​l​l​m​e​n​t​ ​s​t​a​r​t​e​d */ @@ -6338,6 +6555,150 @@ type RootTranslation = { * P​a​s​s​w​o​r​d​ ​r​e​s​e​t​ ​c​o​m​p​l​e​t​e​d */ password_reset_completed: string + /** + * V​P​N​ ​l​o​c​a​t​i​o​n​ ​a​d​d​e​d + */ + vpn_location_added: string + /** + * V​P​N​ ​l​o​c​a​t​i​o​n​ ​r​e​m​o​v​e​d + */ + vpn_location_removed: string + /** + * V​P​N​ ​l​o​c​a​t​i​o​n​ ​m​o​d​i​f​i​e​d + */ + vpn_location_modified: string + /** + * A​P​I​ ​t​o​k​e​n​ ​a​d​d​e​d + */ + api_token_added: string + /** + * A​P​I​ ​t​o​k​e​n​ ​r​e​m​o​v​e​d + */ + api_token_removed: string + /** + * A​P​I​ ​t​o​k​e​n​ ​r​e​n​a​m​e​d + */ + api_token_renamed: string + /** + * O​p​e​n​I​D​ ​a​p​p​ ​a​d​d​e​d + */ + open_id_app_added: string + /** + * O​p​e​n​I​D​ ​a​p​p​ ​r​e​m​o​v​e​d + */ + open_id_app_removed: string + /** + * O​p​e​n​I​D​ ​a​p​p​ ​m​o​d​i​f​i​e​d + */ + open_id_app_modified: string + /** + * O​p​e​n​I​D​ ​a​p​p​ ​s​t​a​t​e​ ​c​h​a​n​g​e​d + */ + open_id_app_state_changed: string + /** + * O​p​e​n​I​D​ ​p​r​o​v​i​d​e​r​ ​r​e​m​o​v​e​d + */ + open_id_provider_removed: string + /** + * O​p​e​n​I​D​ ​p​r​o​v​i​d​e​r​ ​m​o​d​i​f​i​e​d + */ + open_id_provider_modified: string + /** + * S​e​t​t​i​n​g​s​ ​u​p​d​a​t​e​d + */ + settings_updated: string + /** + * S​e​t​t​i​n​g​s​ ​p​a​r​t​i​a​l​l​y​ ​u​p​d​a​t​e​d + */ + settings_updated_partial: string + /** + * D​e​f​a​u​l​t​ ​b​r​a​n​d​i​n​g​ ​r​e​s​t​o​r​e​d + */ + settings_default_branding_restored: string + /** + * G​r​o​u​p​s​ ​b​u​l​k​ ​a​s​s​i​g​n​e​d + */ + groups_bulk_assigned: string + /** + * G​r​o​u​p​ ​a​d​d​e​d + */ + group_added: string + /** + * G​r​o​u​p​ ​m​o​d​i​f​i​e​d + */ + group_modified: string + /** + * G​r​o​u​p​ ​r​e​m​o​v​e​d + */ + group_removed: string + /** + * G​r​o​u​p​ ​m​e​m​b​e​r​ ​a​d​d​e​d + */ + group_member_added: string + /** + * G​r​o​u​p​ ​m​e​m​b​e​r​ ​r​e​m​o​v​e​d + */ + group_member_removed: string + /** + * G​r​o​u​p​ ​m​e​m​b​e​r​s​ ​m​o​d​i​f​i​e​d + */ + group_members_modified: string + /** + * W​e​b​h​o​o​k​ ​a​d​d​e​d + */ + web_hook_added: string + /** + * W​e​b​h​o​o​k​ ​m​o​d​i​f​i​e​d + */ + web_hook_modified: string + /** + * W​e​b​h​o​o​k​ ​r​e​m​o​v​e​d + */ + web_hook_removed: string + /** + * W​e​b​h​o​o​k​ ​s​t​a​t​e​ ​c​h​a​n​g​e​d + */ + web_hook_state_changed: string + /** + * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​k​e​y​ ​a​d​d​e​d + */ + authentication_key_added: string + /** + * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​k​e​y​ ​r​e​m​o​v​e​d + */ + authentication_key_removed: string + /** + * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​k​e​y​ ​r​e​n​a​m​e​d + */ + authentication_key_renamed: string + /** + * P​a​s​s​w​o​r​d​ ​c​h​a​n​g​e​d + */ + password_changed: string + /** + * P​a​s​s​w​o​r​d​ ​c​h​a​n​g​e​d​ ​b​y​ ​a​d​m​i​n + */ + password_changed_by_admin: string + /** + * P​a​s​s​w​o​r​d​ ​r​e​s​e​t + */ + password_reset: string + /** + * C​l​i​e​n​t​ ​c​o​n​f​i​g​u​r​a​t​i​o​n​ ​t​o​k​e​n​ ​a​d​d​e​d + */ + client_configuration_token_added: string + /** + * U​s​e​r​ ​S​N​A​T​ ​b​i​n​d​i​n​g​ ​a​d​d​e​d + */ + user_snat_binding_added: string + /** + * U​s​e​r​ ​S​N​A​T​ ​b​i​n​d​i​n​g​ ​m​o​d​i​f​i​e​d + */ + user_snat_binding_modified: string + /** + * U​s​e​r​ ​S​N​A​T​ ​b​i​n​d​i​n​g​ ​r​e​m​o​v​e​d + */ + user_snat_binding_removed: string } activityLogModule: { /** @@ -6568,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: { /** @@ -7014,6 +7399,36 @@ export type TranslationFunctions = { cancel: () => LocalizedString } } + disableMfa: { + /** + * Disable MFA + */ + title: () => LocalizedString + /** + * Do you want to disable MFA for user {username}? + */ + message: (arg: { username: string }) => LocalizedString + messages: { + /** + * MFA for user {username} has been disabled + */ + success: (arg: { username: string }) => LocalizedString + /** + * Failed to disable MFA for user {username} + */ + error: (arg: { username: string }) => LocalizedString + } + controls: { + /** + * Disable MFA + */ + submit: () => LocalizedString + /** + * Cancel + */ + cancel: () => LocalizedString + } + } startEnrollment: { /** * Start enrollment @@ -7041,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: { /** @@ -7359,7 +7784,7 @@ export type TranslationFunctions = { submit: () => LocalizedString } /** - * Do you want to delete {username} account permanently ? + * Do you want to delete {username} account permanently? */ message: (arg: { username: string }) => LocalizedString messages: { @@ -7527,6 +7952,12 @@ export type TranslationFunctions = { * Add user */ submit: () => LocalizedString + error: { + /** + * Email already taken + */ + emailReserved: () => LocalizedString + } fields: { username: { /** @@ -7757,33 +8188,83 @@ export type TranslationFunctions = { } steps: { setupMethod: { - remote: { - /** - * Configure Desktop Client - */ - title: () => LocalizedString - /** - * A breeze to set up with just a single token. Download the client and enjoy straightforward security. - */ - subTitle: () => LocalizedString - /** - * Download defguard Client - */ - link: () => LocalizedString + /** + * Choose Your Connection Method + */ + title: () => LocalizedString + /** + * You can add a device using this wizard. To proceed, you'll need to install the defguard Client on the device you're adding. You can also use any standard WireGuard® client, but for the best experience and ease of setup, we recommend using our native defguard Client. + */ + message: () => LocalizedString + methods: { + client: { + /** + * Remote Device Activation + */ + title: () => LocalizedString + /** + * Use the Defguard Client to set up your device. Easily configure it with a single token or by scanning a QR code. + */ + description: () => LocalizedString + } + wg: { + /** + * Manual WireGuard Client + */ + title: () => LocalizedString + /** + * For advanced users, get a unique config via download or QR code. Download any WireGuard® client and take control of your VPN setup. + */ + description: () => LocalizedString + } } - manual: { + } + client: { + /** + * Client Activation + */ + title: () => LocalizedString + /** + * 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 + */ + desktopDownload: () => LocalizedString + /** + * Token copied to clipboard + */ + tokenCopy: () => LocalizedString + /** + * Failed to prepare client setup + */ + tokenFailure: () => LocalizedString + labels: { /** - * Manual WireGuard Client + * Defguard Instance Token (new) */ - title: () => LocalizedString + mergedToken: () => LocalizedString /** - * For advanced users, get a unique config via download or QR code. Download the client and take control of your VPN setup. + * Authentication Token */ - subTitle: () => LocalizedString + token: () => LocalizedString /** - * Download WireGuard Client + * URL */ - link: () => LocalizedString + url: () => LocalizedString } } configDevice: { @@ -7850,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.

@@ -8610,6 +9091,10 @@ export type TranslationFunctions = { * Reset password */ resetPassword: () => LocalizedString + /** + * Disable MFA + */ + disableMfa: () => LocalizedString } } } @@ -8926,6 +9411,10 @@ export type TranslationFunctions = { } } components: { + /** + * One-Click Desktop Configuration + */ + openClientDeepLink: () => LocalizedString aclDefaultPolicySelect: { /** * Default ACL Policy @@ -9016,6 +9505,26 @@ export type TranslationFunctions = { contact: () => LocalizedString } } + locationMfaModeSelect: { + /** + * MFA Requirement + */ + label: () => LocalizedString + options: { + /** + * Do not enforce MFA + */ + disabled: () => LocalizedString + /** + * Internal MFA + */ + internal: () => LocalizedString + /** + * External MFA + */ + external: () => LocalizedString + } + } } settingsPage: { /** @@ -9656,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 @@ -9676,7 +10195,7 @@ export type TranslationFunctions = { header: () => LocalizedString /** *

- If your not using some modules you can disable their visibility. + Hide unused modules.

Read more in documentation. @@ -10966,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 */ @@ -10986,6 +11509,38 @@ export type TranslationFunctions = { * ACL functionality is an enterprise feature and you've exceeded the user, device or network limits to use it. In order to use this feature, purchase an enterprise license or upgrade your existing one. */ aclFeatureDisabled: () => LocalizedString + /** + * Clients authorized with MFA will be disconnected from the location once there has been no network activity detected between them and the VPN gateway for a length of time configured below. + */ + peerDisconnectThreshold: () => LocalizedString + locationMfaMode: { + /** + * Choose how MFA is enforced when connecting to this location: + */ + description: () => LocalizedString + /** + * Internal MFA - MFA is enforced using Defguard's built-in MFA (e.g. TOTP, WebAuthn) with internal identity + */ + internal: () => LocalizedString + /** + * External MFA - If configured (see [OpenID settings](settings)) this option uses external identity provider for MFA + */ + external: () => LocalizedString + } + } + sections: { + accessControl: { + /** + * Access Control & Firewall + */ + header: () => LocalizedString + } + mfa: { + /** + * Multi-Factor Authentication + */ + header: () => LocalizedString + } } messages: { /** @@ -11012,7 +11567,7 @@ export type TranslationFunctions = { } endpoint: { /** - * Gateway address + * Gateway IP address or domain name */ label: () => LocalizedString } @@ -11044,12 +11599,6 @@ export type TranslationFunctions = { */ placeholder: () => LocalizedString } - mfa_enabled: { - /** - * Require MFA for this Location - */ - label: () => LocalizedString - } keepalive_interval: { /** * Keepalive interval [seconds] @@ -11058,7 +11607,7 @@ export type TranslationFunctions = { } peer_disconnect_threshold: { /** - * Peer disconnect threshold [seconds] + * Client disconnect threshold [seconds] */ label: () => LocalizedString } @@ -11074,6 +11623,12 @@ export type TranslationFunctions = { */ label: () => LocalizedString } + location_mfa_mode: { + /** + * MFA requirement + */ + label: () => LocalizedString + } } controls: { /** @@ -12448,6 +13003,10 @@ export type TranslationFunctions = { * IP */ ip: () => LocalizedString + /** + * Location + */ + location: () => LocalizedString /** * Event */ @@ -12460,6 +13019,10 @@ export type TranslationFunctions = { * Device */ device: () => LocalizedString + /** + * Description + */ + description: () => LocalizedString } noData: { /** @@ -12511,6 +13074,10 @@ export type TranslationFunctions = { * User modified */ user_modified: () => LocalizedString + /** + * User groups modified + */ + user_groups_modified: () => LocalizedString /** * MFA enabled */ @@ -12519,6 +13086,10 @@ export type TranslationFunctions = { * MFA disabled */ mfa_disabled: () => LocalizedString + /** + * User MFA disabled + */ + user_mfa_disabled: () => LocalizedString /** * MFA TOTP enabled */ @@ -12599,6 +13170,10 @@ export type TranslationFunctions = { * VPN client failed MFA authentication */ vpn_client_mfa_failed: () => LocalizedString + /** + * Enrollment token added + */ + enrollment_token_added: () => LocalizedString /** * Enrollment started */ @@ -12623,6 +13198,150 @@ export type TranslationFunctions = { * Password reset completed */ password_reset_completed: () => LocalizedString + /** + * VPN location added + */ + vpn_location_added: () => LocalizedString + /** + * VPN location removed + */ + vpn_location_removed: () => LocalizedString + /** + * VPN location modified + */ + vpn_location_modified: () => LocalizedString + /** + * API token added + */ + api_token_added: () => LocalizedString + /** + * API token removed + */ + api_token_removed: () => LocalizedString + /** + * API token renamed + */ + api_token_renamed: () => LocalizedString + /** + * OpenID app added + */ + open_id_app_added: () => LocalizedString + /** + * OpenID app removed + */ + open_id_app_removed: () => LocalizedString + /** + * OpenID app modified + */ + open_id_app_modified: () => LocalizedString + /** + * OpenID app state changed + */ + open_id_app_state_changed: () => LocalizedString + /** + * OpenID provider removed + */ + open_id_provider_removed: () => LocalizedString + /** + * OpenID provider modified + */ + open_id_provider_modified: () => LocalizedString + /** + * Settings updated + */ + settings_updated: () => LocalizedString + /** + * Settings partially updated + */ + settings_updated_partial: () => LocalizedString + /** + * Default branding restored + */ + settings_default_branding_restored: () => LocalizedString + /** + * Groups bulk assigned + */ + groups_bulk_assigned: () => LocalizedString + /** + * Group added + */ + group_added: () => LocalizedString + /** + * Group modified + */ + group_modified: () => LocalizedString + /** + * Group removed + */ + group_removed: () => LocalizedString + /** + * Group member added + */ + group_member_added: () => LocalizedString + /** + * Group member removed + */ + group_member_removed: () => LocalizedString + /** + * Group members modified + */ + group_members_modified: () => LocalizedString + /** + * Webhook added + */ + web_hook_added: () => LocalizedString + /** + * Webhook modified + */ + web_hook_modified: () => LocalizedString + /** + * Webhook removed + */ + web_hook_removed: () => LocalizedString + /** + * Webhook state changed + */ + web_hook_state_changed: () => LocalizedString + /** + * Authentication key added + */ + authentication_key_added: () => LocalizedString + /** + * Authentication key removed + */ + authentication_key_removed: () => LocalizedString + /** + * Authentication key renamed + */ + authentication_key_renamed: () => LocalizedString + /** + * Password changed + */ + password_changed: () => LocalizedString + /** + * Password changed by admin + */ + password_changed_by_admin: () => LocalizedString + /** + * Password reset + */ + password_reset: () => LocalizedString + /** + * Client configuration token added + */ + client_configuration_token_added: () => LocalizedString + /** + * User SNAT binding added + */ + user_snat_binding_added: () => LocalizedString + /** + * User SNAT binding modified + */ + user_snat_binding_modified: () => LocalizedString + /** + * User SNAT binding removed + */ + user_snat_binding_removed: () => LocalizedString } activityLogModule: { /** diff --git a/web/src/i18n/ko/index.ts b/web/src/i18n/ko/index.ts index dd4746e133..4dbb5bcf7f 100644 --- a/web/src/i18n/ko/index.ts +++ b/web/src/i18n/ko/index.ts @@ -1,9 +1,10 @@ import { deepmerge } from 'deepmerge-ts'; +import { PartialDeep } from 'type-fest'; import en from '../en'; import { Translation } from '../i18n-types'; -const translation: Translation = { +const translation: PartialDeep = { common: { conditions: { or: '또는', @@ -57,7 +58,7 @@ const translation: Translation = { submit: '그룹 업데이트', }, deleteGroup: { - title: '{name:string} 그룹 삭제', + title: '{name} 그룹 삭제', subTitle: '이 작업은 이 그룹을 영구적으로 삭제합니다.', locationListHeader: '이 그룹은 현재 다음 VPN 위치에 할당되어 있습니다:', locationListFooter: `이것이 주어진 위치에 허용된 유일한 그룹인 경우, 해당 위치는 모든 사용자가 액세스할 수 있게 됩니다.`, @@ -117,7 +118,7 @@ const translation: Translation = { }, }, deleteNetwork: { - title: '{name:string} 위치 삭제', + title: '{name} 위치 삭제', subTitle: '이 작업은 이 위치를 영구적으로 삭제합니다.', submit: '위치 삭제', cancel: '취소', @@ -197,7 +198,7 @@ const translation: Translation = { title: '이메일 MFA 설정', infoMessage: `

- MFA를 설정하려면 계정 이메일: {email: string}로 전송된 코드를 입력하세요 + MFA를 설정하려면 계정 이메일: {email}로 전송된 코드를 입력하세요

`, messages: { @@ -253,9 +254,9 @@ const translation: Translation = { controls: { submit: '계정 삭제', }, - message: '{username: string} 계정을 영구적으로 삭제하시겠습니까?', + message: '{username} 계정을 영구적으로 삭제하시겠습니까?', messages: { - success: '{username: string}이(가) 삭제되었습니다.', + success: '{username}이(가) 삭제되었습니다.', }, }, disableUser: { @@ -263,9 +264,9 @@ const translation: Translation = { controls: { submit: '계정 비활성화', }, - message: '{username: string} 계정을 비활성화하시겠습니까?', + message: '{username} 계정을 비활성화하시겠습니까?', messages: { - success: '{username: string}이(가) 비활성화되었습니다.', + success: '{username}이(가) 비활성화되었습니다.', }, }, enableUser: { @@ -273,9 +274,9 @@ const translation: Translation = { controls: { submit: '계정 활성화', }, - message: '{username: string} 계정을 활성화하시겠습니까?', + message: '{username} 계정을 활성화하시겠습니까?', messages: { - success: '{username: string}이(가) 활성화되었습니다.', + success: '{username}이(가) 활성화되었습니다.', }, }, deleteProvisioner: { @@ -283,9 +284,9 @@ const translation: Translation = { controls: { submit: '프로비저너 삭제', }, - message: '{id: string} 프로비저너를 삭제하시겠습니까?', + message: '{id} 프로비저너를 삭제하시겠습니까?', messages: { - success: '{provisioner: string}이(가) 삭제되었습니다.', + success: '{provisioner}이(가) 삭제되었습니다.', }, }, changeUserPassword: { @@ -415,7 +416,7 @@ const translation: Translation = { }, deleteWebhook: { title: '웹훅 삭제', - message: '{name: string} 웹훅을 삭제하시겠습니까?', + message: '{name} 웹훅을 삭제하시겠습니까?', submit: '삭제', messages: { success: '웹훅이 삭제되었습니다.', @@ -432,20 +433,6 @@ const translation: Translation = { deviceAdded: '장치가 추가되었습니다', }, steps: { - setupMethod: { - remote: { - title: '데스크톱 클라이언트 구성', - subTitle: - '단일 토큰으로 간편하게 설정할 수 있습니다. 클라이언트를 다운로드하고 간단한 보안을 즐기세요.', - link: 'defguard 클라이언트 다운로드', - }, - manual: { - title: '수동 WireGuard 클라이언트', - subTitle: - '고급 사용자의 경우 다운로드 또는 QR 코드를 통해 고유한 구성을 얻으세요. 클라이언트를 다운로드하고 VPN 설정을 제어하세요.', - link: 'WireGuard 클라이언트 다운로드', - }, - }, configDevice: { title: '장치 구성', messages: { @@ -481,7 +468,7 @@ const translation: Translation = { infoMessage: `

장치에서 WireGuardVPN을 구성해야 합니다. 방법을 모르는 경우  - 문서를 참조하세요. + 문서를 참조하세요.

`, options: { @@ -670,7 +657,7 @@ const translation: Translation = { }, deleteModal: { title: '인증 키 삭제', - confirmMessage: '{name: string} 키가 영구적으로 삭제됩니다.', + confirmMessage: '{name} 키가 영구적으로 삭제됩니다.', }, addModal: { header: '새 인증 키 추가', @@ -687,7 +674,7 @@ const translation: Translation = { title: '이름', key: '키', }, - submit: '{name: string} 키 추가', + submit: '{name} 키 추가', }, yubikeyForm: { selectWorker: { @@ -776,8 +763,8 @@ const translation: Translation = { }, copyright: 'Copyright ©2023-2025', version: { - open: '애플리케이션 버전: {version: string}', - closed: 'v{version: string}', + open: '애플리케이션 버전: {version}', + closed: 'v{version}', }, }, form: { @@ -815,8 +802,8 @@ const translation: Translation = { startFromNumber: '숫자로 시작할 수 없습니다.', repeat: `필드가 일치하지 않습니다.`, number: '유효한 숫자를 입력해야 합니다.', - minimumValue: `{value: number}의 최솟값에 도달하지 않았습니다.`, - maximumValue: '{value: number}의 최댓값을 초과했습니다.', + minimumValue: `{value}의 최솟값에 도달하지 않았습니다.`, + maximumValue: '{value}의 최댓값을 초과했습니다.', tooManyBadLoginAttempts: `잘못된 로그인 시도가 너무 많습니다. 몇 분 후에 다시 시도하십시오.`, }, floatingErrors: { @@ -833,11 +820,7 @@ const translation: Translation = { gatewaysStatus: { label: '게이트웨이', states: { - connected: '모두 연결됨', - partial: '하나 이상 작동하지 않음', - disconnected: '연결 끊김', error: '연결 정보를 가져오는 데 실패했습니다.', - loading: '연결 정보를 가져오는 중', }, messages: { error: '게이트웨이 상태를 가져오지 못했습니다', @@ -940,7 +923,7 @@ const translation: Translation = { helper: `

사용하지 않는 모듈이 있는 경우 해당 모듈의 가시성을 비활성화할 수 있습니다.

- + 자세한 내용은 설명서를 참조하십시오. `, fields: { @@ -961,7 +944,7 @@ const translation: Translation = { defaultNetworkSelect: { header: '기본 위치 보기', helper: `

여기에서 기본 위치 보기를 변경할 수 있습니다.

- + 자세한 내용은 설명서를 참조하십시오. `, filterLabels: { @@ -999,7 +982,7 @@ const translation: Translation = { 여기에서 defguard 인스턴스의 로고 및 이름 url을 추가할 수 있습니다. defguard 대신 표시됩니다.

- + 자세한 내용은 설명서를 참조하십시오. `, @@ -1027,7 +1010,6 @@ const translation: Translation = { }, licenseInfo: { title: '라이선스 정보', - noLicense: '라이선스 없음', types: { subscription: { label: '구독', @@ -1213,7 +1195,7 @@ const translation: Translation = { }, deleteApp: { title: '앱 삭제', - message: '{appName: string} 앱을 삭제하시겠습니까?', + message: '{appName} 앱을 삭제하시겠습니까?', submit: '앱 삭제', messages: { success: '앱이 삭제되었습니다.', @@ -1233,7 +1215,7 @@ const translation: Translation = { openidClientModal: { title: { addApp: '애플리케이션 추가', - editApp: '{appName: string} 앱 편집', + editApp: '{appName} 앱 편집', }, scopes: '범위:', messages: { @@ -1255,7 +1237,7 @@ const translation: Translation = { label: '앱 이름', }, redirectUri: { - label: '리디렉션 URL {count: number}', + label: '리디렉션 URL {count}', placeholder: 'https://example.com/redirect', }, openid: { @@ -1363,7 +1345,7 @@ const translation: Translation = { }, }, openidAllow: { - header: '{name: string}이(가) 다음을 원합니다:', + header: '{name}이(가) 다음을 원합니다:', scopes: { openid: '향후 로그인을 위해 프로필 데이터를 사용합니다.', profile: '이름, 프로필 사진 등 프로필의 기본 정보를 알고 있습니다.', @@ -1390,11 +1372,9 @@ const translation: Translation = { }, stats: { currentlyActiveUsers: '현재 활성 사용자', - currentlyActiveDevices: '현재 활성 장치', - activeUsersFilter: '{hour: number}시간 내 활성 사용자', - activeDevicesFilter: '{hour: number}시간 내 활성 장치', - totalTransfer: '총 전송량:', - activityIn: '{hour: number}시간 내 활동', + activeUsersFilter: '{hour}시간 내 활성 사용자', + activeDevicesFilter: '{hour}시간 내 활성 장치', + activityIn: '{hour}시간 내 활동', in: '들어오는 트래픽:', out: '나가는 트래픽:', gatewayDisconnected: '게이트웨이 연결 끊김', @@ -1470,9 +1450,6 @@ const translation: Translation = { label: '허용된 그룹', placeholder: '모든 그룹', }, - mfa_enabled: { - label: '이 위치에 MFA 필요', - }, keepalive_interval: { label: 'Keepalive 간격 [초]', }, @@ -1512,21 +1489,21 @@ const translation: Translation = { }, messages: { runCommand: `Defguard는 vpn 서버에서 wireguard VPN을 제어하기 위해 게이트웨이 노드를 배포해야 합니다. - 자세한 내용은 [문서]({setupGatewayDocs:string})를 참조하십시오. + 자세한 내용은 [문서]({setupGatewayDocs})를 참조하십시오. 게이트웨이 서버를 배포하는 방법에는 여러 가지가 있으며, - 아래는 Docker 기반 예시입니다. 다른 예시는 [문서]({setupGatewayDocs:string})를 참조하십시오.`, + 아래는 Docker 기반 예시입니다. 다른 예시는 [문서]({setupGatewayDocs})를 참조하십시오.`, createNetwork: `게이트웨이 프로세스를 실행하기 전에 네트워크를 생성하십시오.`, noConnection: `연결이 설정되지 않았습니다. 제공된 명령을 실행하십시오.`, connected: `게이트웨이가 연결되었습니다.`, statusError: '게이트웨이 상태를 가져오지 못했습니다', oneLineInstall: `한 줄 설치를 수행하는 경우: https://docs.defguard.net/admin-and-features/setting-up-your-instance/one-line-install 아무 것도 할 필요가 없습니다.`, - fromPackage: `https://github.com/DefGuard/gateway/releases/latest에서 사용 가능한 패키지를 설치하고 [문서]({setupGatewayDocs:string})에 따라 \`/etc/defguard/gateway.toml\`을 구성하십시오. + fromPackage: `https://github.com/DefGuard/gateway/releases/latest에서 사용 가능한 패키지를 설치하고 [문서]({setupGatewayDocs})에 따라 \`/etc/defguard/gateway.toml\`을 구성하십시오. `, authToken: `아래 토큰은 게이트웨이 노드를 인증하고 구성하는 데 필요합니다. 이 토큰을 안전하게 보관하고 - [문서]({setupGatewayDocs:string})에 제공된 배포 지침에 따라 게이트웨이 서버를 성공적으로 설정하십시오. - 자세한 내용 및 정확한 단계는 [문서]({setupGatewayDocs:string})를 참조하십시오.`, - dockerBasedGatewaySetup: `아래는 Docker 기반 예시입니다. 자세한 내용 및 정확한 단계는 [문서]({setupGatewayDocs:string})를 참조하십시오.`, + [문서]({setupGatewayDocs})에 제공된 배포 지침에 따라 게이트웨이 서버를 성공적으로 설정하십시오. + 자세한 내용 및 정확한 단계는 [문서]({setupGatewayDocs})를 참조하십시오.`, + dockerBasedGatewaySetup: `아래는 Docker 기반 예시입니다. 자세한 내용 및 정확한 단계는 [문서]({setupGatewayDocs})를 참조하십시오.`, }, }, loginPage: { diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 8b2f55424a..66c665ffae 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -1,9 +1,10 @@ import { deepmerge } from 'deepmerge-ts'; +import { PartialDeep } from 'type-fest'; import en from '../en'; import { Translation } from '../i18n-types'; -const translation: Translation = { +const translation: PartialDeep = { common: { conditions: { and: 'I', @@ -108,7 +109,7 @@ Informacja o licencjonowaniu: [https://docs.defguard.net/enterprise/license](htt }, deleteStandaloneDevice: { title: 'Usuń urządzenie sieciowe', - content: 'Urządzenie {name: string} zostanie usunięte.', + content: 'Urządzenie {name} zostanie usunięte.', messages: { success: 'Urządzenie zostało usunięte', error: 'Nie udało się usunąć urządzenia.', @@ -624,19 +625,6 @@ Informacja o licencjonowaniu: [https://docs.defguard.net/enterprise/license](htt }, infoMessage: `

W razie problemów możesz odwiedzić dokumentacje.

`, }, - setupMethod: { - manual: { - subTitle: - 'Dla zaawansowanych użytkowników, pobierz konfigurację i skonfiguruj VPN na własnych zasadach.', - link: 'Pobierz WireGuard', - title: 'Konfiguracja ręczna', - }, - remote: { - title: 'Aktywacja klienta desktop', - link: 'Pobierz klienta Defguard', - subTitle: 'Prosta konfiguracja jednym tokenem.', - }, - }, configDevice: { title: 'Skonfiguruj urządzenie', messages: { @@ -726,9 +714,9 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe password: { header: 'Ustawienia hasła', changePassword: 'Zmiana hasła', - ldap_change_heading: 'Wymagana aktualizacja hasła {ldapName:string}', + ldap_change_heading: 'Wymagana aktualizacja hasła {ldapName}', ldap_change_message: - 'Defguard nie ma możliwości odczytania twojego hasła, więc nie możemy go pobrać do automatycznej synchronizacji z danymi logowania {ldapName:string}. Aby umożliwić logowanie do innych usług za pomocą {ldapName:string}, zaktualizuj swoje hasło Defguard, aby jednocześnie ustawić hasło {ldapName:string} — możesz ponownie wpisać swoje obecne hasło, jeśli chcesz. Ten krok jest konieczny, aby zapewnić spójną i bezpieczną autoryzację w obu systemach.', + 'Defguard nie ma możliwości odczytania twojego hasła, więc nie możemy go pobrać do automatycznej synchronizacji z danymi logowania {ldapName}. Aby umożliwić logowanie do innych usług za pomocą {ldapName}, zaktualizuj swoje hasło Defguard, aby jednocześnie ustawić hasło {ldapName} — możesz ponownie wpisać swoje obecne hasło, jeśli chcesz. Ten krok jest konieczny, aby zapewnić spójną i bezpieczną autoryzację w obu systemach.', }, recovery: { header: 'Opcje odzyskiwania danych', @@ -882,7 +870,7 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe }, deleteModal: { title: 'Usuń API Token', - confirmMessage: 'API token {name: string} zostanie trwale usunięty.', + confirmMessage: 'API token {name} zostanie trwale usunięty.', }, addModal: { header: 'Dodaj nowy API Token', @@ -1032,13 +1020,9 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe label: 'Gateways', states: { error: 'Błąd pobierania statusu', - loading: 'Pobieranie informacji', - partial: 'Jeden lub więcej odłączonych', - connected: 'Połączone', - disconnected: 'Brak połączenia', }, messages: { - error: 'Błąd pobierania statusu połączeń gatway', + error: 'Błąd pobierania statusu połączeń gateway', deleteError: 'Błąd usuwania gateway', }, }, @@ -1119,6 +1103,11 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe helper: 'Jeśli ta opcja jest włączona, Defguard automatycznie tworzy nowe konta dla użytkowników, którzy logują się po raz pierwszy za pomocą zewnętrznego dostawcy OpenID. W innym przypadku konto użytkownika musi zostać najpierw utworzone przez administratora.', }, + useOpenIdForMfa: { + label: 'Używaj zewnętrznego OpenID dla MFA klienta', + helper: + 'Gdy zewnętrzny proces Multi-Factor Authentication (MFA) OpenID SSO jest włączony, użytkownicy łączący się z lokalizacjami VPN wymagającymi MFA będą musieli uwierzytelniać się przez swoją przeglądarkę używając skonfigurowanego dostawcy dla każdego połączenia. Jeśli to ustawienie jest wyłączone, MFA dla tych lokalizacji VPN będzie obsługiwane przez wewnętrzny system SSO Defguard. W takim przypadku użytkownicy muszą mieć skonfigurowane TOTP lub MFA oparte na e-mailu.', + }, usernameHandling: { label: 'Obsługa nazw użytkowników', helper: @@ -1233,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: @@ -1333,7 +1327,6 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe }, licenseInfo: { title: 'Informacje o licencji', - noLicense: 'Brak ważnej licencji', licenseNotRequired: "

Posiadasz dostęp do tej funkcji enterprise, ponieważ nie przekroczyłeś jeszcze żadnych limitów. Sprawdź dokumentację, aby uzyskać więcej informacji.

", types: { @@ -1738,10 +1731,8 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe }, stats: { currentlyActiveUsers: 'Obecnie aktywni użytkownicy', - currentlyActiveDevices: 'Obecnie aktywne urządzenia', activeUsersFilter: 'Aktywni użytkownicy w {hour}H', activeDevicesFilter: 'Aktywne urządzenia w {hour}H', - totalTransfer: 'Całkowity transfer:', activityIn: 'Aktywność w {hour}H', in: 'Przychodzący:', out: 'Wychodzący:', @@ -1785,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.', @@ -1804,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', @@ -2110,7 +2103,6 @@ W przypadku innych zgłoszeń skontaktuj się z nami: support@defguard.net labels: { name: 'Nazwa', location: 'Położenie', - assignedIp: 'Adres IP', description: 'Opis', addedBy: 'Dodane przez', addedAt: 'Data dodania', @@ -2181,7 +2173,7 @@ W przypadku innych zgłoszeń skontaktuj się z nami: support@defguard.net modals: { applyConfirm: { title: 'Wdróż oczekujące zmiany', - subtitle: '{count: number} zmian zostanie zastosowanych', + subtitle: '{count} zmian zostanie zastosowanych', submit: 'Wdróż zmiany', }, filterGroupsModal: { @@ -2199,12 +2191,12 @@ W przypadku innych zgłoszeń skontaktuj się z nami: support@defguard.net addNew: 'Dodaj nową', filter: { nothingApplied: 'Filtr', - applied: 'Filtry ({count: number})', + applied: 'Filtry ({count})', }, apply: { noChanges: 'Wdróż oczekujące zmiany', - all: 'Wdróż oczekujące zmiany ({count: number})', - selective: 'Wdróż zaznaczone zmiany ({count: number})', + all: 'Wdróż oczekujące zmiany ({count})', + selective: 'Wdróż zaznaczone zmiany ({count})', }, }, list: { @@ -2257,7 +2249,7 @@ W przypadku innych zgłoszeń skontaktuj się z nami: support@defguard.net title: 'Usuwanie zablokowane', //md content: ` -Ten alias jest obecnie używany przez nastąpujące reguły i nie może być usunięty. Aby go usunąć, należy najpierw wykasować go z tych reguł({rulesCount: number}): +Ten alias jest obecnie używany przez nastąpujące reguły i nie może być usunięty. Aby go usunąć, należy najpierw wykasować go z tych reguł({rulesCount}): `, }, filterGroupsModal: { @@ -2299,12 +2291,12 @@ Ten alias jest obecnie używany przez nastąpujące reguły i nie może być usu addNew: 'Dodaj nową', filter: { nothingApplied: 'Filtr', - applied: 'Filtry ({count: number})', + applied: 'Filtry ({count})', }, apply: { noChanges: 'Wdróż oczkujące zmiany', - all: 'Wdróż oczkujące zmiany ({count: number})', - selective: 'Wdróż zaznaczone zmiany ({count: number})', + all: 'Wdróż oczkujące zmiany ({count})', + selective: 'Wdróż zaznaczone zmiany ({count})', }, }, list: { @@ -2395,8 +2387,8 @@ Ten alias jest obecnie używany przez nastąpujące reguły i nie może być usu }, }, }, -}; +} as PartialDeep; -const pl = deepmerge(en, translation); +const pl = deepmerge(en, translation) as Translation; export default pl; diff --git a/web/src/pages/acl/AclCreateDataProvider.tsx b/web/src/pages/acl/AclCreateDataProvider.tsx index afa457f0c3..b51a93341c 100644 --- a/web/src/pages/acl/AclCreateDataProvider.tsx +++ b/web/src/pages/acl/AclCreateDataProvider.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { PropsWithChildren, useEffect, useMemo } from 'react'; +import { type PropsWithChildren, useEffect, useMemo } from 'react'; import { useLocation } from 'react-router'; import { useSearchParams } from 'react-router-dom'; @@ -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/AclCreatePage/AclCreatePage.tsx b/web/src/pages/acl/AclCreatePage/AclCreatePage.tsx index ecdcb0be87..34d11a545f 100644 --- a/web/src/pages/acl/AclCreatePage/AclCreatePage.tsx +++ b/web/src/pages/acl/AclCreatePage/AclCreatePage.tsx @@ -2,10 +2,10 @@ import './style.scss'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; +import type { AxiosError } from 'axios'; import { intersection } from 'lodash-es'; import { useCallback, useMemo, useRef, useState } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; +import { type SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { useSearchParams } from 'react-router-dom'; import { z } from 'zod'; @@ -34,7 +34,7 @@ import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import useApi from '../../../shared/hooks/useApi'; import { useToaster } from '../../../shared/hooks/useToaster'; import { QueryKeys } from '../../../shared/queries'; -import { +import type { AclRuleInfo, CreateAclRuleRequest, EditAclRuleRequest, @@ -44,11 +44,11 @@ import { User, } from '../../../shared/types'; import { trimObjectStrings } from '../../../shared/utils/trimObjectStrings'; -import { useAclLoadedContext } from '../acl-context'; import { AclAliasKindIcon } from '../AclIndexPage/components/shared/AclAliasKindIcon'; import { AclMessageBoxes } from '../AclIndexPage/components/shared/AclMessageBoxes/AclMessageBoxes'; import { NetworkAccessTypeIcon } from '../AclIndexPage/components/shared/NetworkAccessTypeIcon'; -import { AclAlias, AclAliasStatus } from '../types'; +import { useAclLoadedContext } from '../acl-context'; +import { type AclAlias, AclAliasStatus } from '../types'; import { networkToNetworkAccessType, protocolOptions, protocolToString } from '../utils'; import { aclDestinationValidator, aclPortsValidator } from '../validators'; import { FormDialogSelect } from './components/DialogSelect/FormDialogSelect'; diff --git a/web/src/pages/acl/AclCreatePage/components/DialogSelect/DialogSelect.tsx b/web/src/pages/acl/AclCreatePage/components/DialogSelect/DialogSelect.tsx index 2e1f7dd7fb..7b4bc21f42 100644 --- a/web/src/pages/acl/AclCreatePage/components/DialogSelect/DialogSelect.tsx +++ b/web/src/pages/acl/AclCreatePage/components/DialogSelect/DialogSelect.tsx @@ -12,7 +12,7 @@ import { Label } from '../../../../../shared/defguard-ui/components/Layout/Label import { isPresent } from '../../../../../shared/defguard-ui/utils/isPresent'; import { DialogSelectButtonIcon } from './DialogSelectButtonIcon'; import { DialogSelectModal } from './DialogSelectModal/DialogSelectModal'; -import { DialogSelectProps } from './types'; +import type { DialogSelectProps } from './types'; export const DialogSelect = ({ options, diff --git a/web/src/pages/acl/AclCreatePage/components/DialogSelect/DialogSelectModal/DialogSelectModal.tsx b/web/src/pages/acl/AclCreatePage/components/DialogSelect/DialogSelectModal/DialogSelectModal.tsx index 0cd1ee1936..d970ddc8de 100644 --- a/web/src/pages/acl/AclCreatePage/components/DialogSelect/DialogSelectModal/DialogSelectModal.tsx +++ b/web/src/pages/acl/AclCreatePage/components/DialogSelect/DialogSelectModal/DialogSelectModal.tsx @@ -1,6 +1,6 @@ import './style.scss'; -import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { useI18nContext } from '../../../../../../i18n/i18n-react'; import { Button } from '../../../../../../shared/defguard-ui/components/Layout/Button/Button'; @@ -13,7 +13,7 @@ import { Modal } from '../../../../../../shared/defguard-ui/components/Layout/mo import { Search } from '../../../../../../shared/defguard-ui/components/Layout/Search/Search'; import { isPresent } from '../../../../../../shared/defguard-ui/utils/isPresent'; import { searchByKeys } from '../../../../../../shared/utils/searchByKeys'; -import { DialogSelectProps } from '../types'; +import type { DialogSelectProps } from '../types'; type Props = { initiallySelected: I[]; diff --git a/web/src/pages/acl/AclCreatePage/components/DialogSelect/FormDialogSelect.tsx b/web/src/pages/acl/AclCreatePage/components/DialogSelect/FormDialogSelect.tsx index 89c82241b4..661cf4785a 100644 --- a/web/src/pages/acl/AclCreatePage/components/DialogSelect/FormDialogSelect.tsx +++ b/web/src/pages/acl/AclCreatePage/components/DialogSelect/FormDialogSelect.tsx @@ -1,8 +1,12 @@ import { useMemo } from 'react'; -import { FieldValues, useController, UseControllerProps } from 'react-hook-form'; +import { + type FieldValues, + type UseControllerProps, + useController, +} from 'react-hook-form'; import { DialogSelect } from './DialogSelect'; -import { DialogSelectProps } from './types'; +import type { DialogSelectProps } from './types'; type Props = { controller: UseControllerProps; diff --git a/web/src/pages/acl/AclCreatePage/components/DialogSelect/types.ts b/web/src/pages/acl/AclCreatePage/components/DialogSelect/types.ts index ef2522d3de..e276c3090a 100644 --- a/web/src/pages/acl/AclCreatePage/components/DialogSelect/types.ts +++ b/web/src/pages/acl/AclCreatePage/components/DialogSelect/types.ts @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import type { ReactNode } from 'react'; export type DialogSelectProps = { options: T[]; diff --git a/web/src/pages/acl/AclIndexPage/AclIndexPage.tsx b/web/src/pages/acl/AclIndexPage/AclIndexPage.tsx index 8ddb93f646..9f3a9a57b0 100644 --- a/web/src/pages/acl/AclIndexPage/AclIndexPage.tsx +++ b/web/src/pages/acl/AclIndexPage/AclIndexPage.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from 'react'; import { useI18nContext } from '../../../i18n/i18n-react'; import { PageLayout } from '../../../shared/components/Layout/PageLayout/PageLayout'; import { CardTabs } from '../../../shared/defguard-ui/components/Layout/CardTabs/CardTabs'; -import { CardTabsData } from '../../../shared/defguard-ui/components/Layout/CardTabs/types'; +import type { CardTabsData } from '../../../shared/defguard-ui/components/Layout/CardTabs/types'; import { AclIndexAliases } from './components/AclIndexAliases/AclIndexAliases'; import { AclIndexRules } from './components/AclIndexRules/AclIndexRules'; diff --git a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/AclIndexAliases.tsx b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/AclIndexAliases.tsx index 6a42bc694c..56ad450cf8 100644 --- a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/AclIndexAliases.tsx +++ b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/AclIndexAliases.tsx @@ -7,7 +7,7 @@ import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../../../i18n/i18n-react'; import { FilterGroupsModal } from '../../../../../shared/components/modals/FilterGroupsModal/FilterGroupsModal'; -import { FilterGroupsModalFilter } from '../../../../../shared/components/modals/FilterGroupsModal/types'; +import type { FilterGroupsModalFilter } from '../../../../../shared/components/modals/FilterGroupsModal/types'; import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; import { ButtonSize, @@ -19,7 +19,7 @@ import useApi from '../../../../../shared/hooks/useApi'; import { useToaster } from '../../../../../shared/hooks/useToaster'; import { QueryKeys } from '../../../../../shared/queries'; import { useAclLoadedContext } from '../../../acl-context'; -import { AclAlias, AclAliasStatus } from '../../../types'; +import { type AclAlias, AclAliasStatus } from '../../../types'; import { aclAliasStatusToInt, aclDestinationToListTagDisplay, @@ -34,7 +34,7 @@ import { AclAliasApplyConfirmModal } from './modals/AclAliasApplyConfirmModal/Ac import { AclAliasDeleteBlockModal } from './modals/AclAliasDeleteBlockModal/AclAliasDeleteBlockModal'; import { AlcAliasCEModal } from './modals/AlcAliasCEModal/AlcAliasCEModal'; import { useAclAliasCEModal } from './modals/AlcAliasCEModal/store'; -import { AclAliasListData } from './types'; +import type { AclAliasListData } from './types'; type ListTagDisplay = { key: string | number; @@ -236,6 +236,7 @@ export const AclIndexAliases = () => { const filters = useMemo(() => { const res: Record = { rules: { + identifier: 'rules', label: localLL.modals.filterGroupsModal.groupLabels.rules(), items: aclRules?.map((rule) => ({ @@ -246,6 +247,7 @@ export const AclIndexAliases = () => { order: 2, }, status: { + identifier: 'status', label: localLL.modals.filterGroupsModal.groupLabels.status(), items: [ { diff --git a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/components/AliasEditButton.tsx b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/components/AliasEditButton.tsx index 48fe8d87d5..b13f1802f9 100644 --- a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/components/AliasEditButton.tsx +++ b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/components/AliasEditButton.tsx @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; +import type { AxiosError } from 'axios'; import { useCallback } from 'react'; import { shallow } from 'zustand/shallow'; @@ -13,7 +13,7 @@ import { QueryKeys } from '../../../../../../shared/queries'; import { AclAliasStatus } from '../../../../types'; import { useAclAliasDeleteBlockModal } from '../modals/AclAliasDeleteBlockModal/store'; import { useAclAliasCEModal } from '../modals/AlcAliasCEModal/store'; -import { AclAliasListData } from '../types'; +import type { AclAliasListData } from '../types'; type EditProps = { alias: AclAliasListData; diff --git a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/components/AliasesList.tsx b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/components/AliasesList.tsx index 071f00ea13..cdcc24bf92 100644 --- a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/components/AliasesList.tsx +++ b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/components/AliasesList.tsx @@ -1,21 +1,21 @@ import clsx from 'clsx'; import { orderBy } from 'lodash-es'; -import { ReactNode, useMemo, useState } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { upperCaseFirst } from 'text-case'; import { useI18nContext } from '../../../../../../i18n/i18n-react'; import { ListCellTags } from '../../../../../../shared/components/Layout/ListCellTags/ListCellTags'; -import { ListCellText } from '../../../../../../shared/components/Layout/ListCellText/ListCellText'; import { ListHeader } from '../../../../../../shared/components/Layout/ListHeader/ListHeader'; -import { ListHeaderColumnConfig } from '../../../../../../shared/components/Layout/ListHeader/types'; +import type { ListHeaderColumnConfig } from '../../../../../../shared/components/Layout/ListHeader/types'; import { CheckBox } from '../../../../../../shared/defguard-ui/components/Layout/Checkbox/CheckBox'; import { InteractionBox } from '../../../../../../shared/defguard-ui/components/Layout/InteractionBox/InteractionBox'; +import { ListCellText } from '../../../../../../shared/defguard-ui/components/Layout/ListCellText/ListCellText'; import { NoData } from '../../../../../../shared/defguard-ui/components/Layout/NoData/NoData'; import { ListSortDirection } from '../../../../../../shared/defguard-ui/components/Layout/VirtualizedList/types'; import { isPresent } from '../../../../../../shared/defguard-ui/utils/isPresent'; -import { AclAlias } from '../../../../types'; +import type { AclAlias } from '../../../../types'; import { DividerHeader } from '../../shared/DividerHeader'; -import { AclAliasListData } from '../types'; +import type { AclAliasListData } from '../types'; import { AclAliasStatusDisplay } from './AclAliasStatus/AclAliasStatus'; import { AliasEditButton } from './AliasEditButton'; diff --git a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AclAliasDeleteBlockModal/AclAliasDeleteBlockModal.tsx b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AclAliasDeleteBlockModal/AclAliasDeleteBlockModal.tsx index b3ebc3509f..0cd72d66ba 100644 --- a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AclAliasDeleteBlockModal/AclAliasDeleteBlockModal.tsx +++ b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AclAliasDeleteBlockModal/AclAliasDeleteBlockModal.tsx @@ -18,6 +18,7 @@ export const AclAliasDeleteBlockModal = () => { const rules = useAclAliasDeleteBlockModal((s) => s.rulesNames); const isOpen = useAclAliasDeleteBlockModal((s) => s.visible); + // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater useEffect(() => { return () => { reset?.(); diff --git a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AclAliasDeleteBlockModal/store.tsx b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AclAliasDeleteBlockModal/store.tsx index 049937e413..a6685026a2 100644 --- a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AclAliasDeleteBlockModal/store.tsx +++ b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AclAliasDeleteBlockModal/store.tsx @@ -1,6 +1,6 @@ import { createWithEqualityFn } from 'zustand/traditional'; -import { AclAlias } from '../../../../../types'; +import type { AclAlias } from '../../../../../types'; const defaults: StoreValues = { visible: false, 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 cec67fae2b..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 @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useQueryClient } from '@tanstack/react-query'; import { omit } from 'lodash-es'; import { useEffect, useMemo } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; +import { type SubmitHandler, useForm } from 'react-hook-form'; import { z } from 'zod'; import { shallow } from 'zustand/shallow'; @@ -17,7 +17,7 @@ import { ButtonStyleVariant, } from '../../../../../../../shared/defguard-ui/components/Layout/Button/types'; import { ModalWithTitle } from '../../../../../../../shared/defguard-ui/components/Layout/modals/ModalWithTitle/ModalWithTitle'; -import { SelectOption } from '../../../../../../../shared/defguard-ui/components/Layout/Select/types'; +import type { SelectOption } from '../../../../../../../shared/defguard-ui/components/Layout/Select/types'; import { isPresent } from '../../../../../../../shared/defguard-ui/utils/isPresent'; import useApi from '../../../../../../../shared/hooks/useApi'; import { useToaster } from '../../../../../../../shared/hooks/useToaster'; @@ -35,6 +35,7 @@ export const AlcAliasCEModal = () => { const [close, reset] = useAclAliasCEModal((s) => [s.close, s.reset], shallow); + // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater useEffect(() => { return () => { reset(); @@ -83,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/AclIndexPage/components/AclIndexAliases/modals/AlcAliasCEModal/store.tsx b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AlcAliasCEModal/store.tsx index 7a089ded2a..72ba8e2b69 100644 --- a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AlcAliasCEModal/store.tsx +++ b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/modals/AlcAliasCEModal/store.tsx @@ -1,6 +1,6 @@ import { createWithEqualityFn } from 'zustand/traditional'; -import { AclAlias } from '../../../../../types'; +import type { AclAlias } from '../../../../../types'; const defaults: StoreValues = { visible: false, diff --git a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/types.ts b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/types.ts index fe1c17f0ad..3c13d9ea71 100644 --- a/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/types.ts +++ b/web/src/pages/acl/AclIndexPage/components/AclIndexAliases/types.ts @@ -1,5 +1,5 @@ -import { AclAlias } from '../../../types'; -import { ListCellTag } from '../shared/types'; +import type { AclAlias } from '../../../types'; +import type { ListCellTag } from '../shared/types'; export type AclAliasListData = { display: { diff --git a/web/src/pages/acl/AclIndexPage/components/AclIndexRules/AclIndexRules.tsx b/web/src/pages/acl/AclIndexPage/components/AclIndexRules/AclIndexRules.tsx index cbb3ed8fe1..d277cf2809 100644 --- a/web/src/pages/acl/AclIndexPage/components/AclIndexRules/AclIndexRules.tsx +++ b/web/src/pages/acl/AclIndexPage/components/AclIndexRules/AclIndexRules.tsx @@ -1,20 +1,19 @@ import './style.scss'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; +import type { AxiosError } from 'axios'; import clsx from 'clsx'; import { concat, intersection, orderBy } from 'lodash-es'; -import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; import { upperCaseFirst } from 'text-case'; import { useI18nContext } from '../../../../../i18n/i18n-react'; import { ListCellTags } from '../../../../../shared/components/Layout/ListCellTags/ListCellTags'; -import { ListCellText } from '../../../../../shared/components/Layout/ListCellText/ListCellText'; import { ListHeader } from '../../../../../shared/components/Layout/ListHeader/ListHeader'; -import { ListHeaderColumnConfig } from '../../../../../shared/components/Layout/ListHeader/types'; +import type { ListHeaderColumnConfig } from '../../../../../shared/components/Layout/ListHeader/types'; import { FilterGroupsModal } from '../../../../../shared/components/modals/FilterGroupsModal/FilterGroupsModal'; -import { FilterGroupsModalFilter } from '../../../../../shared/components/modals/FilterGroupsModal/types'; +import type { FilterGroupsModalFilter } from '../../../../../shared/components/modals/FilterGroupsModal/types'; import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; import { ButtonSize, @@ -25,6 +24,7 @@ import { EditButton } from '../../../../../shared/defguard-ui/components/Layout/ import { EditButtonOption } from '../../../../../shared/defguard-ui/components/Layout/EditButton/EditButtonOption'; import { EditButtonOptionStyleVariant } from '../../../../../shared/defguard-ui/components/Layout/EditButton/types'; import { InteractionBox } from '../../../../../shared/defguard-ui/components/Layout/InteractionBox/InteractionBox'; +import { ListCellText } from '../../../../../shared/defguard-ui/components/Layout/ListCellText/ListCellText'; import { ListItemCount } from '../../../../../shared/defguard-ui/components/Layout/ListItemCount/ListItemCount'; import { NoData } from '../../../../../shared/defguard-ui/components/Layout/NoData/NoData'; import { Search } from '../../../../../shared/defguard-ui/components/Layout/Search/Search'; @@ -33,21 +33,21 @@ import { isPresent } from '../../../../../shared/defguard-ui/utils/isPresent'; import useApi from '../../../../../shared/hooks/useApi'; import { useToaster } from '../../../../../shared/hooks/useToaster'; import { QueryKeys } from '../../../../../shared/queries'; -import { AclRuleInfo } from '../../../../../shared/types'; +import type { AclRuleInfo } from '../../../../../shared/types'; import { useAclLoadedContext } from '../../../acl-context'; import { - AclAlias, + type AclAlias, AclAliasStatus, - AclCreateContextLoaded, + type AclCreateContextLoaded, AclStatus, } from '../../../types'; import { aclRuleToStatusInt, aclStatusToInt } from '../../../utils'; import { AclListSkeleton } from '../AclListSkeleton/AclListSkeleton'; import { DeployChangesIcon } from '../DeployChangesIcon'; import { DividerHeader } from '../shared/DividerHeader'; -import { ListCellTag } from '../shared/types'; -import { AclRulesApplyConfirmModal } from './components/AclRulesApplyConfirmModal/AclRulesApplyConfirmModal'; +import type { ListCellTag } from '../shared/types'; import { AclRuleStatus } from './components/AclRuleStatus/AclRuleStatus'; +import { AclRulesApplyConfirmModal } from './components/AclRulesApplyConfirmModal/AclRulesApplyConfirmModal'; type RulesFilters = { networks: number[]; diff --git a/web/src/pages/acl/AclIndexPage/components/shared/DividerHeader.tsx b/web/src/pages/acl/AclIndexPage/components/shared/DividerHeader.tsx index 73dd1145a7..86243ea08b 100644 --- a/web/src/pages/acl/AclIndexPage/components/shared/DividerHeader.tsx +++ b/web/src/pages/acl/AclIndexPage/components/shared/DividerHeader.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; type DividerHeaderProps = { text: string; diff --git a/web/src/pages/acl/AclRoutes.tsx b/web/src/pages/acl/AclRoutes.tsx index 5ab928767f..9c83f80894 100644 --- a/web/src/pages/acl/AclRoutes.tsx +++ b/web/src/pages/acl/AclRoutes.tsx @@ -1,10 +1,9 @@ -import { PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; import { Route, Routes } from 'react-router'; - -import { AclCreateTrackedProvider } from './acl-context'; import { AclCreateDataProvider } from './AclCreateDataProvider'; import { AlcCreatePage } from './AclCreatePage/AclCreatePage'; import { AclIndexPage } from './AclIndexPage/AclIndexPage'; +import { AclCreateTrackedProvider } from './acl-context'; const AclProvide = ({ children }: PropsWithChildren) => { return ( diff --git a/web/src/pages/acl/acl-context.tsx b/web/src/pages/acl/acl-context.tsx index 6474ce66d0..6713bb0764 100644 --- a/web/src/pages/acl/acl-context.tsx +++ b/web/src/pages/acl/acl-context.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'; import { createContainer } from 'react-tracked'; -import { AclCreateContext, AclCreateContextLoaded } from './types'; +import type { AclCreateContext, AclCreateContextLoaded } from './types'; const init: AclCreateContext = { devices: undefined, diff --git a/web/src/pages/acl/types.ts b/web/src/pages/acl/types.ts index b895eae765..aeb6b6a961 100644 --- a/web/src/pages/acl/types.ts +++ b/web/src/pages/acl/types.ts @@ -1,4 +1,4 @@ -import { +import type { AclRuleInfo, GroupInfo, Network, diff --git a/web/src/pages/acl/utils.ts b/web/src/pages/acl/utils.ts index 944879182f..abbca3b6ff 100644 --- a/web/src/pages/acl/utils.ts +++ b/web/src/pages/acl/utils.ts @@ -1,6 +1,6 @@ -import { SelectOption } from '../../shared/defguard-ui/components/Layout/Select/types'; -import { AclRuleInfo, Network } from '../../shared/types'; -import { ListCellTag } from './AclIndexPage/components/shared/types'; +import type { SelectOption } from '../../shared/defguard-ui/components/Layout/Select/types'; +import type { AclRuleInfo, Network } from '../../shared/types'; +import type { ListCellTag } from './AclIndexPage/components/shared/types'; import { AclAliasStatus, AclProtocol, AclStatus, NetworkAccessType } from './types'; // used by acl rules index page, bcs we don't show Applied in UI but instead enabled / disabled when state is "applied" diff --git a/web/src/pages/acl/validators.ts b/web/src/pages/acl/validators.ts index 9a05738cfc..83151eb687 100644 --- a/web/src/pages/acl/validators.ts +++ b/web/src/pages/acl/validators.ts @@ -1,7 +1,7 @@ import * as ipaddr from 'ipaddr.js'; import { z } from 'zod'; -import { TranslationFunctions } from '../../i18n/i18n-types'; +import type { TranslationFunctions } from '../../i18n/i18n-types'; import { patternStrictIpV4 } from '../../shared/patterns'; export const aclPortsValidator = (LL: TranslationFunctions) => @@ -23,8 +23,8 @@ export const aclPortsValidator = (LL: TranslationFunctions) => .filter((v) => v !== ''); const found: number[] = []; for (const entry of trimmed) { - const num = parseInt(entry); - if (isNaN(num)) { + const num = parseInt(entry, 10); + if (Number.isNaN(num)) { return false; } if (found.includes(num)) { @@ -86,8 +86,8 @@ function parseSubnet(input: string): [ipaddr.IPv4 | ipaddr.IPv6, number] | null const kind = ip.kind(); if (kind === 'ipv6') { - const prefix = parseInt(maskPart); - if (typeof prefix !== 'number' || isNaN(prefix)) { + const prefix = parseInt(maskPart, 10); + if (typeof prefix !== 'number' || Number.isNaN(prefix)) { return null; } return [ip, prefix]; diff --git a/web/src/pages/activity-log/ActivityLogPage.tsx b/web/src/pages/activity-log/ActivityLogPage.tsx index dab0f2b3ca..7f1b42b45f 100644 --- a/web/src/pages/activity-log/ActivityLogPage.tsx +++ b/web/src/pages/activity-log/ActivityLogPage.tsx @@ -1,6 +1,6 @@ import './style.scss'; -import { QueryKey, useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { type QueryKey, useInfiniteQuery, useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { range } from 'lodash-es'; import { useMemo, useState } from 'react'; @@ -9,9 +9,8 @@ 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 { FilterGroupsModalFilter } from '../../shared/components/modals/FilterGroupsModal/types'; +import type { FilterGroupsModalFilter } from '../../shared/components/modals/FilterGroupsModal/types'; import { Button } from '../../shared/defguard-ui/components/Layout/Button/Button'; import { ButtonSize } from '../../shared/defguard-ui/components/Layout/Button/types'; import { Card } from '../../shared/defguard-ui/components/Layout/Card/Card'; @@ -20,23 +19,22 @@ import { NoData } from '../../shared/defguard-ui/components/Layout/NoData/NoData import { Search } from '../../shared/defguard-ui/components/Layout/Search/Search'; import { ListSortDirection } from '../../shared/defguard-ui/components/Layout/VirtualizedList/types'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; +import { useAuthStore } from '../../shared/hooks/store/useAuthStore'; import useApi from '../../shared/hooks/useApi'; -import { ActivityLogSortKey } from '../../shared/types'; +import type { ActivityLogSortKey } from '../../shared/types'; import { ActivityList } from './components/ActivityList'; import { ActivityTimeRangeModal } from './components/ActivityTimeRangeModal'; import { - ActivityLogEventType, + type ActivityLogEventType, + type ActivityLogModule, activityLogEventTypeValues, - ActivityLogModule, activityLogModuleValues, } from './types'; export const ActivityLogPage = () => { return ( - - - - + + ); }; @@ -58,7 +56,7 @@ const applySearch = (val: string): string | undefined => { return undefined; }; -type Filters = 'event' | 'username' | 'module'; +type Filters = 'event' | 'username' | 'module' | 'location'; const PageContent = () => { const [activeFilters, setActiveFilters] = useState< @@ -67,6 +65,7 @@ const PageContent = () => { event: [], module: [], username: [], + location: [], }); const [searchValue, setSearchValue] = useState(''); const [filtersModalOpen, setFiltersModalOpen] = useState(false); @@ -77,6 +76,7 @@ const PageContent = () => { const [sortDirection, setSortDirection] = useState( ListSortDirection.DESC, ); + const isAdmin = useAuthStore((s) => s.user?.is_admin ?? false); const activeFiltersCount = useMemo( () => Object.values(activeFilters).flat().length, @@ -89,11 +89,18 @@ const PageContent = () => { const { activityLog: { getActivityLog }, user: { getUsers }, + network: { getNetworks }, } = useApi(); const { data: users } = useQuery({ queryFn: getUsers, queryKey: ['user'], + enabled: isAdmin, + }); + + const { data: locations } = useQuery({ + queryFn: getNetworks, + queryKey: ['location'], }); const queryKey = useMemo( @@ -128,6 +135,7 @@ const PageContent = () => { event: applyFilterArray(activeFilters.event as ActivityLogEventType[]), module: applyFilterArray(activeFilters.module as ActivityLogModule[]), username: applyFilterArray(activeFilters.username as string[]), + location: applyFilterArray(activeFilters.location as string[]), sort_order: sortDirection, sort_by: sortKey, search: applySearch(searchValue), @@ -146,7 +154,7 @@ const PageContent = () => { const filterOptions = useMemo(() => { const res: Record = {}; if (users) { - res['users'] = { + res.users = { label: 'Users', identifier: 'username', order: 3, @@ -157,7 +165,19 @@ const PageContent = () => { })), }; } - res['module'] = { + if (locations) { + res.locations = { + label: 'Locations', + identifier: 'location', + order: 4, + items: locations.map((location) => ({ + label: location.name, + searchValues: [location.name], + value: location.name, + })), + }; + } + res.module = { identifier: 'module', label: 'Module', order: 2, @@ -170,7 +190,7 @@ const PageContent = () => { }; }), }; - res['event'] = { + res.event = { identifier: 'event', label: 'Event', order: 1, @@ -184,11 +204,11 @@ const PageContent = () => { }), }; return res; - }, [LL.enums, users]); + }, [LL.enums, users, locations]); const activityData = useMemo(() => { if (data) { - return data.pages.map((page) => page.data).flat(1); + return data.pages.flatMap((page) => page.data); } return undefined; }, [data]); diff --git a/web/src/pages/activity-log/components/ActivityList.tsx b/web/src/pages/activity-log/components/ActivityList.tsx index 8e1cf9b0f5..552a1a491c 100644 --- a/web/src/pages/activity-log/components/ActivityList.tsx +++ b/web/src/pages/activity-log/components/ActivityList.tsx @@ -4,12 +4,12 @@ import { useMemo, useRef } from 'react'; import { useInView } from 'react-intersection-observer'; import { useI18nContext } from '../../../i18n/i18n-react'; -import { ListCellText } from '../../../shared/components/Layout/ListCellText/ListCellText'; import { ListHeader } from '../../../shared/components/Layout/ListHeader/ListHeader'; -import { ListHeaderColumnConfig } from '../../../shared/components/Layout/ListHeader/types'; +import type { ListHeaderColumnConfig } from '../../../shared/components/Layout/ListHeader/types'; +import { ListCellText } from '../../../shared/defguard-ui/components/Layout/ListCellText/ListCellText'; import { LoaderSpinner } from '../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; -import { ListSortDirection } from '../../../shared/defguard-ui/components/Layout/VirtualizedList/types'; -import { ActivityLogEvent, ActivityLogSortKey } from '../../../shared/types'; +import type { ListSortDirection } from '../../../shared/defguard-ui/components/Layout/VirtualizedList/types'; +import type { ActivityLogEvent, ActivityLogSortKey } from '../../../shared/types'; type Props = { data: ActivityLogEvent[]; @@ -72,6 +72,10 @@ export const ActivityList = ({ label: headersLL.ip(), key: 'ip', }, + { + label: headersLL.location(), + key: 'location', + }, { label: headersLL.event(), key: 'event', @@ -84,6 +88,10 @@ export const ActivityList = ({ label: headersLL.device(), key: 'device', }, + { + label: headersLL.description(), + key: 'description', + }, ], [headersLL], ); @@ -135,6 +143,9 @@ export const ActivityList = ({
+
+ +
@@ -144,6 +155,9 @@ export const ActivityList = ({
+
+ +
); })} diff --git a/web/src/pages/activity-log/components/ActivityTimeRangeModal.tsx b/web/src/pages/activity-log/components/ActivityTimeRangeModal.tsx index cac4dd9292..34c9f83232 100644 --- a/web/src/pages/activity-log/components/ActivityTimeRangeModal.tsx +++ b/web/src/pages/activity-log/components/ActivityTimeRangeModal.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; +import { type SubmitHandler, useForm } from 'react-hook-form'; import { z } from 'zod'; import { useI18nContext } from '../../../i18n/i18n-react'; @@ -69,40 +69,38 @@ const ModalContent = ({ onOpenChange, activityFrom, activityUntil, onChange }: P }; return ( - <> -
- + + +
+
- - + + ); }; diff --git a/web/src/pages/activity-log/style.scss b/web/src/pages/activity-log/style.scss index 8f24a18b78..aecfdc5474 100644 --- a/web/src/pages/activity-log/style.scss +++ b/web/src/pages/activity-log/style.scss @@ -37,7 +37,7 @@ } @mixin list-sizing() { - grid-template-columns: 250px 150px 150px 300px 100px minmax(200px, 1fr); + grid-template-columns: 150px 120px 150px 150px 300px 100px 200px minmax(300px, 1fr); justify-content: space-between; column-gap: var(--spacing-xs); diff --git a/web/src/pages/activity-log/types.ts b/web/src/pages/activity-log/types.ts index 05a9404354..3485b92434 100644 --- a/web/src/pages/activity-log/types.ts +++ b/web/src/pages/activity-log/types.ts @@ -17,7 +17,9 @@ export type ActivityLogEventType = | 'user_added' | 'user_modified' | 'user_removed' + | 'user_groups_modified' | 'mfa_disabled' + | 'user_mfa_disabled' | 'mfa_totp_enabled' | 'mfa_totp_disabled' | 'mfa_email_enabled' @@ -38,24 +40,63 @@ export type ActivityLogEventType = | 'vpn_client_connected_mfa' | 'vpn_client_disconnected_mfa' | 'vpn_client_mfa_failed' + | 'enrollment_token_added' | 'enrollment_started' | 'enrollment_device_added' | 'enrollment_completed' | 'password_reset_requested' | 'password_reset_started' - | 'password_reset_completed'; + | 'password_reset_completed' + | 'vpn_location_added' + | 'vpn_location_removed' + | 'vpn_location_modified' + | 'api_token_added' + | 'api_token_removed' + | 'api_token_renamed' + | 'open_id_app_added' + | 'open_id_app_removed' + | 'open_id_app_modified' + | 'open_id_app_state_changed' + | 'open_id_provider_removed' + | 'open_id_provider_modified' + | 'settings_updated' + | 'settings_updated_partial' + | 'settings_default_branding_restored' + | 'groups_bulk_assigned' + | 'group_added' + | 'group_modified' + | 'group_removed' + | 'group_member_added' + | 'group_member_removed' + | 'group_members_modified' + | 'web_hook_added' + | 'web_hook_modified' + | 'web_hook_removed' + | 'web_hook_state_changed' + | 'authentication_key_added' + | 'authentication_key_removed' + | 'authentication_key_renamed' + | 'password_changed' + | 'password_changed_by_admin' + | 'password_reset' + | 'client_configuration_token_added' + | 'user_snat_binding_added' + | 'user_snat_binding_modified' + | 'user_snat_binding_removed'; export const activityLogEventTypeValues: ActivityLogEventType[] = [ 'user_login', 'user_login_failed', 'user_mfa_login', 'user_mfa_login_failed', + 'user_groups_modified', 'recovery_code_used', 'user_logout', 'user_added', 'user_modified', 'user_removed', 'mfa_disabled', + 'user_mfa_disabled', 'mfa_totp_enabled', 'mfa_totp_disabled', 'mfa_email_enabled', @@ -76,10 +117,47 @@ export const activityLogEventTypeValues: ActivityLogEventType[] = [ 'vpn_client_connected_mfa', 'vpn_client_disconnected_mfa', 'vpn_client_mfa_failed', + 'enrollment_token_added', 'enrollment_started', 'enrollment_device_added', 'enrollment_completed', 'password_reset_requested', 'password_reset_started', 'password_reset_completed', + 'vpn_location_added', + 'vpn_location_removed', + 'vpn_location_modified', + 'api_token_added', + 'api_token_removed', + 'api_token_renamed', + 'open_id_app_added', + 'open_id_app_removed', + 'open_id_app_modified', + 'open_id_app_state_changed', + 'open_id_provider_removed', + 'open_id_provider_modified', + 'settings_updated', + 'settings_updated_partial', + 'settings_default_branding_restored', + 'groups_bulk_assigned', + 'group_added', + 'group_modified', + 'group_removed', + 'group_member_added', + 'group_member_removed', + 'group_members_modified', + 'web_hook_added', + 'web_hook_modified', + 'web_hook_removed', + 'web_hook_state_changed', + 'authentication_key_added', + 'authentication_key_removed', + 'authentication_key_renamed', + 'password_changed', + 'password_changed_by_admin', + 'password_reset', + 'client_configuration_token_added', + 'user_snat_binding_added', + 'user_snat_binding_modified', + 'user_snat_binding_removed', ]; diff --git a/web/src/pages/addDevice/AddDevicePage.tsx b/web/src/pages/addDevice/AddDevicePage.tsx index 1e2a70e1b8..8366ab9608 100644 --- a/web/src/pages/addDevice/AddDevicePage.tsx +++ b/web/src/pages/addDevice/AddDevicePage.tsx @@ -1,6 +1,6 @@ import './style.scss'; -import { ReactNode, useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { useNavigate } from 'react-router'; import { shallow } from 'zustand/shallow'; @@ -17,39 +17,38 @@ import { ButtonStyleVariant, } from '../../shared/defguard-ui/components/Layout/Button/types'; import { useAppStore } from '../../shared/hooks/store/useAppStore'; +import { useAuthStore } from '../../shared/hooks/store/useAuthStore'; +import { useEnterpriseUpgradeStore } from '../../shared/hooks/store/useEnterpriseUpgradeStore'; +import useApi from '../../shared/hooks/useApi'; import { useAddDevicePageStore } from './hooks/useAddDevicePageStore'; +import { AddDeviceClientConfigurationStep } from './steps/AddDeviceClientConfigurationStep/AddDeviceClientConfigurationStep'; import { AddDeviceConfigStep } from './steps/AddDeviceConfigStep/AddDeviceConfigStep'; import { AddDeviceSetupMethodStep } from './steps/AddDeviceSetupMethodStep/AddDeviceSetupMethodStep'; import { AddDeviceSetupStep } from './steps/AddDeviceSetupStep/AddDeviceSetupStep'; -import { AddDeviceTokenStep } from './steps/AddDeviceTokenStep/AddDeviceTokenStep'; -import { AddDeviceMethod } from './types'; +import { AddDeviceNavigationEvent, AddDeviceStep } from './types'; + +const finalSteps: AddDeviceStep[] = [ + AddDeviceStep.NATIVE_CONFIGURATION, + AddDeviceStep.CLIENT_CONFIGURATION, +]; export const AddDevicePage = () => { const { LL } = useI18nContext(); const pageLL = LL.addDevicePage; const navigate = useNavigate(); + const { getAppInfo } = useApi(); const userData = useAddDevicePageStore((state) => state.userData); - const enterpriseSettings = useAppStore((state) => state.enterprise_settings); - - const [currentStep, setupMethod] = useAddDevicePageStore( - (state) => [state.currentStep, state.method], + const isAdmin = useAuthStore((s) => s.user?.is_admin ?? false); + const setAppStore = useAppStore((s) => s.setState); + const showUpgradeToast = useEnterpriseUpgradeStore((s) => s.show); + const currentStep = useAddDevicePageStore((state) => state.currentStep); + const [navSubject, resetStore, setStep] = useAddDevicePageStore( + (s) => [s.navigationSubject, s.reset, s.setStep], shallow, ); - const nextSubject = useAddDevicePageStore((state) => state.nextSubject); - - const steps = useMemo((): ReactNode[] => { - if (setupMethod === AddDeviceMethod.MANUAL) { - return manualSteps; - } - return desktopSteps; - }, [setupMethod]); - - const stepsMax = useMemo( - () => (setupMethod === AddDeviceMethod.MANUAL ? 2 : 1), - [setupMethod], - ); + const isFinalStep = finalSteps.includes(currentStep); useEffect(() => { if (!userData) { @@ -57,6 +56,50 @@ export const AddDevicePage = () => { } }, [navigate, userData]); + useEffect(() => { + const sub = navSubject.subscribe((event) => { + if ( + event === AddDeviceNavigationEvent.NEXT && + [AddDeviceStep.CLIENT_CONFIGURATION, AddDeviceStep.NATIVE_CONFIGURATION].includes( + currentStep, + ) && + userData + ) { + if (isAdmin) { + void getAppInfo().then((resp) => { + setAppStore({ appInfo: resp }); + if (resp.license_info.any_limit_exceeded) { + showUpgradeToast(); + } + }); + } + navigate(userData.originRoutePath, { replace: true }); + setTimeout(() => { + resetStore(); + }, 250); + } + if (event === AddDeviceNavigationEvent.BACK) { + if (currentStep === AddDeviceStep.NATIVE_CHOOSE_METHOD) { + setStep(AddDeviceStep.CHOOSE_METHOD); + } + } + }); + return () => { + sub.unsubscribe(); + }; + }, [ + currentStep, + getAppInfo, + isAdmin, + navSubject, + navigate, + resetStore, + setAppStore, + setStep, + showUpgradeToast, + userData, + ]); + return (
@@ -64,43 +107,63 @@ export const AddDevicePage = () => {

{pageLL.title()}

- {currentStep === 0 && } - {currentStep !== 0 && steps[currentStep - 1]} + {steps[currentStep]}
); }; -const manualSteps: ReactNode[] = [ - , - , -]; -const desktopSteps: ReactNode[] = []; +const steps = { + [AddDeviceStep.CHOOSE_METHOD]: , + [AddDeviceStep.NATIVE_CHOOSE_METHOD]: , + [AddDeviceStep.NATIVE_CONFIGURATION]: , + [AddDeviceStep.CLIENT_CONFIGURATION]: , +}; diff --git a/web/src/pages/addDevice/hooks/useAddDevicePageStore.tsx b/web/src/pages/addDevice/hooks/useAddDevicePageStore.tsx index 2cdcb74caa..583a7f3e39 100644 --- a/web/src/pages/addDevice/hooks/useAddDevicePageStore.tsx +++ b/web/src/pages/addDevice/hooks/useAddDevicePageStore.tsx @@ -3,44 +3,38 @@ import { Subject } from 'rxjs'; import { createJSONStorage, persist } from 'zustand/middleware'; import { createWithEqualityFn } from 'zustand/traditional'; -import { DeviceConfigsCardNetworkInfo } from '../../../shared/components/network/DeviceConfigsCard/types'; -import { AddDeviceResponseDevice } from '../../../shared/types'; -import { AddDeviceMethod } from '../types'; +import type { DeviceConfigsCardNetworkInfo } from '../../../shared/components/network/DeviceConfigsCard/types'; +import type { AddDeviceResponseDevice } from '../../../shared/types'; +import { type AddDeviceNavigationEvent, AddDeviceStep } from '../types'; const defaultValues: StoreValues = { - nextSubject: new Subject(), - currentStep: 0, - method: AddDeviceMethod.DESKTOP, + navigationSubject: new Subject(), + currentStep: AddDeviceStep.CHOOSE_METHOD, userData: undefined, loading: false, publicKey: undefined, privateKey: undefined, device: undefined, networks: undefined, - enrollment: undefined, + clientSetup: undefined, }; export const useAddDevicePageStore = createWithEqualityFn()( persist( - (set, get) => ({ + (set) => ({ ...defaultValues, - nextStep: (values) => { - const current = get().currentStep; - if (values) { - set({ ...values, currentStep: current + 1 }); - } else { - set({ currentStep: current + 1 }); - } - }, reset: () => set(defaultValues), init: (userData) => { set({ ...defaultValues, userData }); }, setState: (values) => set({ ...values }), + setStep: (step, values) => { + set({ ...values, currentStep: step }); + }, }), { name: 'add-device-store', - partialize: (store) => omit(store, ['nextSubject', 'loading']), + partialize: (store) => omit(store, ['navigationSubject', 'loading']), storage: createJSONStorage(() => sessionStorage), }, ), @@ -50,10 +44,9 @@ export const useAddDevicePageStore = createWithEqualityFn()( type Store = StoreValues & StoreMethods; type StoreValues = { + navigationSubject: Subject; + currentStep: AddDeviceStep; loading: boolean; - nextSubject: Subject; - currentStep: number; - method: AddDeviceMethod; privateKey?: string; publicKey?: string; device?: AddDeviceResponseDevice; @@ -66,15 +59,15 @@ type StoreValues = { // this should be current path that user entered add-device page from, due to brave blocking history relative back doesn't work correctly. originRoutePath: string; }; - enrollment?: { + clientSetup?: { token: string; url: string; }; }; type StoreMethods = { - nextStep: (values?: Partial) => void; init: (userData: StoreValues['userData']) => void; reset: () => void; setState: (values: Partial) => void; + setStep: (step: AddDeviceStep, values?: Partial) => void; }; diff --git a/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/AddDeviceClientConfigurationStep.tsx b/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/AddDeviceClientConfigurationStep.tsx new file mode 100644 index 0000000000..eaf9bf3914 --- /dev/null +++ b/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/AddDeviceClientConfigurationStep.tsx @@ -0,0 +1,306 @@ +import './style.scss'; + +import { useEffect } from 'react'; +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, + ButtonStyleVariant, +} from '../../../../shared/defguard-ui/components/Layout/Button/types'; +import { Card } from '../../../../shared/defguard-ui/components/Layout/Card/Card'; +import { CopyField } from '../../../../shared/defguard-ui/components/Layout/CopyField/CopyField'; +import { MessageBox } from '../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; +import { isPresent } from '../../../../shared/defguard-ui/utils/isPresent'; +import { useClipboard } from '../../../../shared/hooks/useClipboard'; +import { externalLink } from '../../../../shared/links'; +import { useAddDevicePageStore } from '../../hooks/useAddDevicePageStore'; +import { AddDeviceStep } from '../../types'; +import { enrollmentToImportToken } from '../../utils/enrollmentToToken'; + +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) + : null, + ); + const setStep = useAddDevicePageStore((s) => s.setStep, shallow); + const { writeToClipboard } = useClipboard(); + + useEffect(() => { + if (!isPresent(tokenValue)) { + setStep(AddDeviceStep.CHOOSE_METHOD); + } + }, [setStep, tokenValue]); + + if (!isPresent(tokenValue) || !isPresent(clientData)) return null; + + return ( + +

{localLL.title()}

+ {isPresent(clientSetup) && ( + <> + +
+ +
+ + )} + + + + {/* { + void writeToClipboard(value, localLL.tokenCopy()); + }} + /> */} + { + void writeToClipboard(value, localLL.tokenCopy()); + }} + /> + { + void writeToClipboard(value, localLL.tokenCopy()); + }} + /> + +
+ +
+
+

{localLL.qrDescription()}

+
+ +
+ ); +}; diff --git a/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/style.scss b/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/style.scss new file mode 100644 index 0000000000..df62be874d --- /dev/null +++ b/web/src/pages/addDevice/steps/AddDeviceClientConfigurationStep/style.scss @@ -0,0 +1,50 @@ +#add-device-client-configuration { + .message-box.spacer { + padding-bottom: var(--spacing-s); + } + + .qr-description { + color: var(--text-body-secondary); + text-align: center; + max-width: 480px; + user-select: none; + @include typography(app-input); + } + + .copy-field.spacer { + &:not(:last-of-type) { + padding-bottom: var(--spacing-s); + } + } + + .row { + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; + column-gap: var(--spacing-xs); + + &:not(:last-child) { + padding-bottom: var(--spacing-s); + } + + a { + display: flex; + cursor: pointer; + user-select: none; + } + } + + .links:first-of-type { + padding-bottom: var(--spacing-s); + } + + .qr { + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; + padding: var(--spacing-m) var(--spacing-s) var(--spacing-l); + box-sizing: border-box; + } +} diff --git a/web/src/pages/addDevice/steps/AddDeviceConfigStep/AddDeviceConfigStep.tsx b/web/src/pages/addDevice/steps/AddDeviceConfigStep/AddDeviceConfigStep.tsx index f176df555a..ca397de7ee 100644 --- a/web/src/pages/addDevice/steps/AddDeviceConfigStep/AddDeviceConfigStep.tsx +++ b/web/src/pages/addDevice/steps/AddDeviceConfigStep/AddDeviceConfigStep.tsx @@ -3,7 +3,6 @@ import './style.scss'; import parse from 'html-react-parser'; import { isUndefined } from 'lodash-es'; import { useEffect, useMemo } from 'react'; -import { useNavigate } from 'react-router'; import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../../i18n/i18n-react'; @@ -12,11 +11,8 @@ import { Card } from '../../../../shared/defguard-ui/components/Layout/Card/Card import { Input } from '../../../../shared/defguard-ui/components/Layout/Input/Input'; import { MessageBox } from '../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; import { MessageBoxType } from '../../../../shared/defguard-ui/components/Layout/MessageBox/types'; -import { useAppStore } from '../../../../shared/hooks/store/useAppStore'; -import { useAuthStore } from '../../../../shared/hooks/store/useAuthStore'; -import { useEnterpriseUpgradeStore } from '../../../../shared/hooks/store/useEnterpriseUpgradeStore'; -import useApi from '../../../../shared/hooks/useApi'; import { useAddDevicePageStore } from '../../hooks/useAddDevicePageStore'; +import { AddDeviceStep } from '../../types'; enum SetupMode { AUTO, @@ -26,11 +22,6 @@ enum SetupMode { export const AddDeviceConfigStep = () => { const { LL } = useI18nContext(); const localLL = LL.addDevicePage.steps.configDevice; - const navigate = useNavigate(); - const { getAppInfo } = useApi(); - const isAdmin = useAuthStore((s) => s.user?.is_admin); - const setAppStore = useAppStore((s) => s.setState); - const showUpgradeToast = useEnterpriseUpgradeStore((s) => s.show); const [userData, device, publicKey, privateKey, networks] = useAddDevicePageStore( (state) => [ @@ -43,8 +34,7 @@ export const AddDeviceConfigStep = () => { shallow, ); - const nextSubject = useAddDevicePageStore((state) => state.nextSubject, shallow); - const resetPageState = useAddDevicePageStore((state) => state.reset); + const setStep = useAddDevicePageStore((state) => state.setStep, shallow); const setupMode = isUndefined(privateKey) ? SetupMode.MANUAL : SetupMode.AUTO; @@ -56,27 +46,10 @@ export const AddDeviceConfigStep = () => { }, [localLL.helpers, setupMode]); useEffect(() => { - const sub = nextSubject.subscribe(() => { - if (userData) { - if (isAdmin) { - void getAppInfo().then((response) => { - setAppStore({ appInfo: response }); - if (response.license_info.any_limit_exceeded) { - showUpgradeToast(); - } - }); - } - navigate(userData.originRoutePath, { replace: true }); - setTimeout(() => { - resetPageState(); - }, 1000); - } - }); - return () => { - sub.unsubscribe(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAdmin, nextSubject, userData]); + if (!device || !userData || !publicKey || !networks) { + setStep(AddDeviceStep.NATIVE_CHOOSE_METHOD); + } + }, [device, networks, publicKey, setStep, userData]); if (!device || !userData || !publicKey || !networks) return null; diff --git a/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/AddDeviceSetupMethodStep.tsx b/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/AddDeviceSetupMethodStep.tsx index b87ed04e4d..26aa2eb03f 100644 --- a/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/AddDeviceSetupMethodStep.tsx +++ b/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/AddDeviceSetupMethodStep.tsx @@ -1,23 +1,19 @@ import './style.scss'; import { useMutation } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../../i18n/i18n-react'; -import SvgDefguardNavLogo from '../../../../shared/components/svg/DefguardNavLogo'; -import SvgWireguardLogo from '../../../../shared/components/svg/WireguardLogo'; import { Card } from '../../../../shared/defguard-ui/components/Layout/Card/Card'; import { LoaderSpinner } from '../../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; import { MessageBox } from '../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; -import { MessageBoxType } from '../../../../shared/defguard-ui/components/Layout/MessageBox/types'; -import useEffectOnce from '../../../../shared/helpers/useEffectOnce'; import { useAppStore } from '../../../../shared/hooks/store/useAppStore'; import useApi from '../../../../shared/hooks/useApi'; -import { externalLink } from '../../../../shared/links'; import { useAddDevicePageStore } from '../../hooks/useAddDevicePageStore'; -import { AddDeviceMethod } from '../../types'; +import { AddDeviceNavigationEvent, AddDeviceStep } from '../../types'; import { DeviceSetupMethodCard } from './components/DeviceSetupMethodCard/DeviceSetupMethodCard'; +import { DeviceSetupMethod } from './types'; export const AddDeviceSetupMethodStep = () => { const { @@ -25,22 +21,21 @@ export const AddDeviceSetupMethodStep = () => { } = useApi(); const { LL } = useI18nContext(); const localLL = LL.addDevicePage.steps.setupMethod; - const setupMethod = useAddDevicePageStore((state) => state.method); - const methodRef = useRef(setupMethod); + const [setupMethod, setSetupMethod] = useState(AddDeviceStep.CLIENT_CONFIGURATION); const userData = useAddDevicePageStore((state) => state.userData); - const enterpriseSettings = useAppStore((state) => state.enterprise_settings); - const [setPageState, next, nextSubject] = useAddDevicePageStore( - (state) => [state.setState, state.nextStep, state.nextSubject], + const enterpriseSettings = useAppStore((state) => state.enterprise_settings); + const [navSubject, setPageState, setStep] = useAddDevicePageStore( + (s) => [s.navigationSubject, s.setState, s.setStep], shallow, ); - const { isPending: isLoading, mutate } = useMutation({ + const { mutate, isPending } = useMutation({ mutationFn: startDesktopActivation, onSuccess: (resp) => { - next({ - enrollment: { + setStep(setupMethod, { + clientSetup: { url: resp.enrollment_url, token: resp.enrollment_token, }, @@ -48,78 +43,65 @@ export const AddDeviceSetupMethodStep = () => { }, }); + const startActivation = useCallback(() => { + mutate({ + username: userData?.username as string, + send_enrollment_notification: true, + email: userData?.email as string, + }); + }, [mutate, userData?.email, userData?.username]); + useEffect(() => { - const sub = nextSubject.subscribe(() => { - if (methodRef.current === AddDeviceMethod.MANUAL) { - next(); - } else { - mutate({ - username: userData?.username as string, - send_enrollment_notification: true, - email: userData?.email as string, - }); + const sub = navSubject.subscribe((event) => { + if (event === AddDeviceNavigationEvent.NEXT) { + switch (setupMethod) { + case AddDeviceStep.NATIVE_CHOOSE_METHOD: + setPageState({ currentStep: AddDeviceStep.NATIVE_CHOOSE_METHOD }); + break; + case AddDeviceStep.CLIENT_CONFIGURATION: + startActivation(); + break; + } } }); return () => { sub.unsubscribe(); }; - }, [nextSubject, next, userData?.username, userData?.email, methodRef, mutate]); + }, [navSubject, setPageState, setupMethod, startActivation]); useEffect(() => { - methodRef.current = setupMethod; - }, [setupMethod]); - - useEffect(() => { - setPageState({ loading: isLoading }); - }, [isLoading, setPageState]); - - useEffectOnce(() => { - if (enterpriseSettings?.only_client_activation) { - setPageState({ method: AddDeviceMethod.DESKTOP }); - nextSubject.next(); + if ( + enterpriseSettings?.only_client_activation && + setupMethod === AddDeviceStep.NATIVE_CHOOSE_METHOD + ) { + setSetupMethod(AddDeviceStep.CLIENT_CONFIGURATION); } - }); + }, [enterpriseSettings?.only_client_activation, setupMethod]); return ( <> - {!enterpriseSettings?.only_client_activation ? ( - <> - - + {!isPending ? ( + +

{localLL.title()}

+ +
} - linkText={localLL.remote.link()} - link={externalLink.defguardReleases} - selected={setupMethod === AddDeviceMethod.DESKTOP} - onSelect={() => { - if (setupMethod !== AddDeviceMethod.DESKTOP) { - setPageState({ method: AddDeviceMethod.DESKTOP }); - } + methodType={DeviceSetupMethod.CLIENT} + active={setupMethod === AddDeviceStep.CLIENT_CONFIGURATION} + onClick={() => { + setSetupMethod(AddDeviceStep.CLIENT_CONFIGURATION); }} /> } - linkText={localLL.manual.link()} - link={externalLink.wireguard.download} - selected={setupMethod === AddDeviceMethod.MANUAL} - onSelect={() => { - if (setupMethod !== AddDeviceMethod.MANUAL) { - setPageState({ method: AddDeviceMethod.MANUAL }); - } + disabled={enterpriseSettings?.only_client_activation ?? false} + methodType={DeviceSetupMethod.NATIVE_WG} + active={setupMethod === AddDeviceStep.NATIVE_CHOOSE_METHOD} + onClick={() => { + setSetupMethod(AddDeviceStep.NATIVE_CHOOSE_METHOD); }} /> - - +
+
) : (
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 8f926c78b1..cb1dbecc57 100644 --- a/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/DeviceSetupMethodCard.tsx +++ b/web/src/pages/addDevice/steps/AddDeviceSetupMethodStep/components/DeviceSetupMethodCard/DeviceSetupMethodCard.tsx @@ -1,55 +1,365 @@ import './style.scss'; -import { isUndefined } from 'lodash-es'; -import { ReactNode } from 'react'; +import clsx from 'clsx'; +import { type PropsWithChildren, type ReactNode, useId, useMemo } from 'react'; -import SvgIconCheckmarkWhite from '../../../../../../shared/components/svg/IconCheckmarkWhite'; -import { Button } from '../../../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { - ButtonSize, - ButtonStyleVariant, -} from '../../../../../../shared/defguard-ui/components/Layout/Button/types'; -import SvgIconOutsideLink from '../../../../../../shared/defguard-ui/components/svg/IconOutsideLink'; +import { useI18nContext } from '../../../../../../i18n/i18n-react'; +import { isPresent } from '../../../../../../shared/defguard-ui/utils/isPresent'; +import { DeviceSetupMethod } from '../../types'; -type Props = { +type StandaloneConfig = { + icon: ReactNode; title: string; - subtitle: string; - logo: ReactNode; - selected: boolean; - link?: string; - linkText?: string; - onSelect: () => void; - testId?: string; + description: string; + testId: string; + extras?: ReactNode; +}; + +type Props = { + active: boolean; + onClick: () => void; + methodType?: DeviceSetupMethod; + custom?: StandaloneConfig; + disabled?: boolean; }; + +type ContentConfiguration = { + title: string; + description: string; + testId: string; +} & Pick & + PropsWithChildren; + export const DeviceSetupMethodCard = ({ - title, - link, - linkText, - selected, - logo, - subtitle, - onSelect, - testId, + methodType, + active, + onClick, + custom, + disabled = false, }: Props) => { + const { LL } = useI18nContext(); + const localLL = LL.addDevicePage.steps.setupMethod.methods; + + const [title, description, testId] = useMemo(() => { + if (!isPresent(custom) && methodType) { + const testId = `add-device-method-${methodType.valueOf()}`; + switch (methodType) { + case DeviceSetupMethod.CLIENT: + return [localLL.client.title(), localLL.client.description(), testId]; + case DeviceSetupMethod.NATIVE_WG: + return [localLL.wg.title(), localLL.wg.description(), testId]; + default: + throw Error('Unimplemented setup method supplied to method card.'); + } + } + if (isPresent(custom)) { + return [custom.title, custom.description, custom.testId]; + } + throw Error('Bad props for DeviceSetupMethodCard'); + }, [custom, localLL.client, localLL.wg, methodType]); + return ( -
-

{title}

-

{subtitle}

- {logo &&
{logo}
} - diff --git a/web/src/pages/network/NetworkEditForm/components/DividerHeader.tsx b/web/src/pages/network/NetworkEditForm/components/DividerHeader.tsx new file mode 100644 index 0000000000..86243ea08b --- /dev/null +++ b/web/src/pages/network/NetworkEditForm/components/DividerHeader.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren } from 'react'; + +type DividerHeaderProps = { + text: string; +} & PropsWithChildren; + +export const DividerHeader = ({ text, children }: DividerHeaderProps) => { + return ( +
+
+

{text}

+ {children} +
+
+ ); +}; diff --git a/web/src/pages/network/NetworkEditForm/style.scss b/web/src/pages/network/NetworkEditForm/style.scss index deaf9ead94..c8840e9d88 100644 --- a/web/src/pages/network/NetworkEditForm/style.scss +++ b/web/src/pages/network/NetworkEditForm/style.scss @@ -29,4 +29,33 @@ } } } + + #location-mfa-mode-explain-message-box { + ul { + list-style-position: inside; + margin-top: 8px; + + li { + p { + display: inline; + } + } + } + } + + .divider-header { + padding-bottom: var(--spacing-s); + + .inner { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + border-bottom: 1px solid var(--border-primary); + } + + .header { + @include typography(app-side-bar); + } + } } diff --git a/web/src/pages/network/NetworkGateway/NetworkGateway.tsx b/web/src/pages/network/NetworkGateway/NetworkGateway.tsx index de53a4df3a..319c863552 100644 --- a/web/src/pages/network/NetworkGateway/NetworkGateway.tsx +++ b/web/src/pages/network/NetworkGateway/NetworkGateway.tsx @@ -31,9 +31,10 @@ export const NetworkGatewaySetup = () => { const { data: networkToken } = useQuery({ queryKey: [QueryKeys.FETCH_NETWORK_TOKEN, selectedNetworkId], - queryFn: () => getNetworkToken(selectedNetworkId), + queryFn: () => getNetworkToken(selectedNetworkId as number), refetchOnMount: true, refetchOnWindowFocus: false, + enabled: Boolean(selectedNetworkId), }); const command = useCallback(() => { @@ -100,16 +101,14 @@ export const NetworkGatewaySetup = () => { {networkToken && ( - <> - -

{returnNetworkToken()}

-
- + +

{returnNetworkToken()}

+
)}

{LL.gatewaySetup.header.dockerBasedGatewaySetup()}

@@ -122,16 +121,14 @@ export const NetworkGatewaySetup = () => { {networkToken && ( - <> - -

{command()}

-
- + +

{command()}

+
)}

{LL.gatewaySetup.header.fromPackage()}

diff --git a/web/src/pages/network/hooks/useNetworkPageStore.ts b/web/src/pages/network/hooks/useNetworkPageStore.ts index bc5709b925..edac1fce34 100644 --- a/web/src/pages/network/hooks/useNetworkPageStore.ts +++ b/web/src/pages/network/hooks/useNetworkPageStore.ts @@ -1,13 +1,13 @@ import { Subject } from 'rxjs'; import { createWithEqualityFn } from 'zustand/traditional'; -import { Network } from '../../../shared/types'; +import type { Network } from '../../../shared/types'; type NetworkPageStore = { saveSubject: Subject; loading: boolean; networks: Network[]; - selectedNetworkId: number; + selectedNetworkId?: number; setState: (data: Partial) => void; setNetworks: (data: Network[]) => void; }; @@ -17,13 +17,14 @@ export const useNetworkPageStore = createWithEqualityFn()( saveSubject: new Subject(), loading: false, networks: [], - selectedNetworkId: 1, + selectedNetworkId: undefined, setState: (newState) => set(() => newState), setNetworks: (networks) => { + const sortedNetworks = networks.sort((a, b) => a.name.localeCompare(b.name)); if (get().selectedNetworkId === undefined) { - set({ selectedNetworkId: networks[0]?.id }); + set({ selectedNetworkId: sortedNetworks[0]?.id }); } - set({ networks }); + set({ networks: sortedNetworks }); }, }), Object.is, diff --git a/web/src/pages/openid/OpenidClientsListPage/OpenidClientsListPage.tsx b/web/src/pages/openid/OpenidClientsListPage/OpenidClientsListPage.tsx index f645c3b2d4..91ddb2d7ee 100644 --- a/web/src/pages/openid/OpenidClientsListPage/OpenidClientsListPage.tsx +++ b/web/src/pages/openid/OpenidClientsListPage/OpenidClientsListPage.tsx @@ -25,13 +25,13 @@ import { ConfirmModalType } from '../../../shared/defguard-ui/components/Layout/ import { NoData } from '../../../shared/defguard-ui/components/Layout/NoData/NoData'; import { Search } from '../../../shared/defguard-ui/components/Layout/Search/Search'; import { Select } from '../../../shared/defguard-ui/components/Layout/Select/Select'; -import { +import type { SelectOption, SelectSelectedValue, } from '../../../shared/defguard-ui/components/Layout/Select/types'; import { - ListHeader, - ListRowCell, + type ListHeader, + type ListRowCell, ListSortDirection, } from '../../../shared/defguard-ui/components/Layout/VirtualizedList/types'; import { VirtualizedList } from '../../../shared/defguard-ui/components/Layout/VirtualizedList/VirtualizedList'; @@ -41,7 +41,7 @@ import { useClipboard } from '../../../shared/hooks/useClipboard'; import { useToaster } from '../../../shared/hooks/useToaster'; import { MutationKeys } from '../../../shared/mutations'; import { QueryKeys } from '../../../shared/queries'; -import { OpenidClient } from '../../../shared/types'; +import type { OpenidClient } from '../../../shared/types'; import { OpenIdClientModal } from '../modals/OpenIdClientModal/OpenIdClientModal'; export const OpenidClientsListPage = () => { @@ -268,7 +268,7 @@ export const OpenidClientsListPage = () => { if (breakpoint !== 'desktop' && selectedFilter !== FilterOption.ALL) { setSelectedFilter(FilterOption.ALL); } - }, [breakpoint, selectOptions, selectedFilter]); + }, [breakpoint, selectedFilter]); return ( diff --git a/web/src/pages/openid/modals/OpenIdClientModal/OpenIdClientModalForm.tsx b/web/src/pages/openid/modals/OpenIdClientModal/OpenIdClientModalForm.tsx index 7007552bcc..e6d4af4d13 100644 --- a/web/src/pages/openid/modals/OpenIdClientModal/OpenIdClientModalForm.tsx +++ b/web/src/pages/openid/modals/OpenIdClientModal/OpenIdClientModalForm.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import classNames from 'classnames'; import { isUndefined } from 'lodash-es'; import { useMemo } from 'react'; -import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'; +import { type SubmitHandler, useFieldArray, useForm } from 'react-hook-form'; import { z } from 'zod'; import { useI18nContext } from '../../../../i18n/i18n-react'; @@ -23,7 +23,7 @@ import { useToaster } from '../../../../shared/hooks/useToaster'; import { MutationKeys } from '../../../../shared/mutations'; import { QueryKeys } from '../../../../shared/queries'; import { OpenIdClientModalFormScopes } from './components/OpenIdClientModalFormScopes'; -import { OpenIdClientFormFields, OpenIdClientScope } from './types'; +import { type OpenIdClientFormFields, OpenIdClientScope } from './types'; const defaultValuesEmptyForm: OpenIdClientFormFields = { name: '', @@ -95,6 +95,7 @@ export const OpenIdClientModalForm = () => { 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,18 +103,21 @@ export const OpenIdClientModalForm = () => { z.object({ url: z .string() + .trim() .min( 1, LL.openidOverview.modals.openidClientModal.form.error.urlRequired(), ), }), ), - scope: z.array(z.string()).optional(), + scope: z.array(z.nativeEnum(OpenIdClientScope)), }), [LL.form.error, LL.openidOverview.modals.openidClientModal.form.error], ); - const { handleSubmit, control } = useForm({ + type FormFields = z.infer; + + const { handleSubmit, control } = useForm({ defaultValues: defaultFormValues, mode: 'all', resolver: zodResolver(zodSchema), @@ -124,7 +128,7 @@ export const OpenIdClientModalForm = () => { name: 'redirect_uri', }); - const onValidSubmit: SubmitHandler = (values) => { + const onValidSubmit: SubmitHandler = (values) => { if (modalState.viewMode) return; if (values.scope.length === 0) { toaster.error( diff --git a/web/src/pages/openid/modals/OpenIdClientModal/components/OpenIdClientModalFormScopes.tsx b/web/src/pages/openid/modals/OpenIdClientModal/components/OpenIdClientModalFormScopes.tsx index 09832f29d8..3495c0c6e7 100644 --- a/web/src/pages/openid/modals/OpenIdClientModal/components/OpenIdClientModalFormScopes.tsx +++ b/web/src/pages/openid/modals/OpenIdClientModal/components/OpenIdClientModalFormScopes.tsx @@ -1,9 +1,9 @@ import { useCallback } from 'react'; -import { Control, useController } from 'react-hook-form'; +import { type Control, useController } from 'react-hook-form'; import { useI18nContext } from '../../../../../i18n/i18n-react'; import { LabeledCheckbox } from '../../../../../shared/defguard-ui/components/Layout/LabeledCheckbox/LabeledCheckbox'; -import { OpenIdClientFormFields, OpenIdClientScope } from '../types'; +import { type OpenIdClientFormFields, OpenIdClientScope } from '../types'; type Props = { control: Control; diff --git a/web/src/pages/overview-index/OverviewIndexPage.tsx b/web/src/pages/overview-index/OverviewIndexPage.tsx index 4903d259b4..555c99d743 100644 --- a/web/src/pages/overview-index/OverviewIndexPage.tsx +++ b/web/src/pages/overview-index/OverviewIndexPage.tsx @@ -1,7 +1,7 @@ import './style.scss'; import { useQuery } from '@tanstack/react-query'; -import { range } from 'lodash-es'; +import { orderBy, range } from 'lodash-es'; import { useEffect } from 'react'; import Skeleton from 'react-loading-skeleton'; import { useLocation, useNavigate } from 'react-router'; @@ -18,7 +18,7 @@ import { import { NoData } from '../../shared/defguard-ui/components/Layout/NoData/NoData'; import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import useApi from '../../shared/hooks/useApi'; -import { Network } from '../../shared/types'; +import type { Network } from '../../shared/types'; import { OverviewStats } from '../overview/OverviewStats/OverviewStats'; import { useWizardStore } from '../wizard/hooks/useWizardStore'; import { EditLocationsSettingsButton } from './components/EditLocationsSettingsButton/EditLocationsSettingsButton'; @@ -35,11 +35,14 @@ export const OverviewIndexPage = () => { queryKey: ['network'], queryFn: getNetworks, placeholderData: (perv) => perv, + select: (networks) => + orderBy(networks, (network) => network.name.toLowerCase(), ['asc']), }); const resetWizard = useWizardStore((state) => state.resetState); const navigate = useNavigate(); + // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater useEffect(() => { if (isPresent(data) && data.length === 0 && !isLoading && !isStale) { resetWizard(); diff --git a/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx b/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx index c8ea52b0b9..91403bc624 100644 --- a/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx +++ b/web/src/pages/overview-index/components/EditLocationsSettingsButton/EditLocationsSettingsButton.tsx @@ -13,11 +13,11 @@ 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 = () => { - if (!isNaN(selectedNetwork)) { + if (!Number.isNaN(selectedNetwork)) { setNetworkPageStore({ selectedNetworkId: selectedNetwork, }); diff --git a/web/src/pages/overview-index/components/OverviewNetworkSelection/OverviewNetworkSelection.tsx b/web/src/pages/overview-index/components/OverviewNetworkSelection/OverviewNetworkSelection.tsx index 5f8f544159..3c4f11685d 100644 --- a/web/src/pages/overview-index/components/OverviewNetworkSelection/OverviewNetworkSelection.tsx +++ b/web/src/pages/overview-index/components/OverviewNetworkSelection/OverviewNetworkSelection.tsx @@ -1,11 +1,12 @@ import { useQuery } from '@tanstack/react-query'; +import { orderBy } from 'lodash-es'; import { useMemo } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useI18nContext } from '../../../../i18n/i18n-react'; import { Select } from '../../../../shared/defguard-ui/components/Layout/Select/Select'; import { - SelectOption, + type SelectOption, SelectSizeVariant, } from '../../../../shared/defguard-ui/components/Layout/Select/types'; import { isPresent } from '../../../../shared/defguard-ui/utils/isPresent'; @@ -26,12 +27,14 @@ export const OverviewNetworkSelection = () => { queryKey: ['network'], queryFn: getNetworks, placeholderData: (perv) => perv, + select: (networks) => + orderBy(networks, (network) => network.name.toLowerCase(), ['asc']), }); const selectionValue = useMemo(() => { if (networkId) { - const value = parseInt(networkId); - if (!isNaN(value) && typeof value === 'number') { + const value = parseInt(networkId, 10); + if (!Number.isNaN(value) && typeof value === 'number') { return value; } } diff --git a/web/src/pages/overview-index/components/OverviewTimeSelection/OverviewTimeSelection.tsx b/web/src/pages/overview-index/components/OverviewTimeSelection/OverviewTimeSelection.tsx index bf393f7128..44c4539fa6 100644 --- a/web/src/pages/overview-index/components/OverviewTimeSelection/OverviewTimeSelection.tsx +++ b/web/src/pages/overview-index/components/OverviewTimeSelection/OverviewTimeSelection.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useI18nContext } from '../../../../i18n/i18n-react'; import { Select } from '../../../../shared/defguard-ui/components/Layout/Select/Select'; import { - SelectOption, + type SelectOption, SelectSizeVariant, } from '../../../../shared/defguard-ui/components/Layout/Select/types'; import { useOverviewTimeSelection } from '../hooks/useOverviewTimeSelection'; diff --git a/web/src/pages/overview-index/components/hooks/useOverviewTimeSelection.ts b/web/src/pages/overview-index/components/hooks/useOverviewTimeSelection.ts index d1b51d411f..a42cd64c93 100644 --- a/web/src/pages/overview-index/components/hooks/useOverviewTimeSelection.ts +++ b/web/src/pages/overview-index/components/hooks/useOverviewTimeSelection.ts @@ -7,8 +7,8 @@ export const useOverviewTimeSelection = () => { const fromValue = useMemo((): number => { const searchValue = searchParams.get('from'); if (searchValue) { - const parsed = parseInt(searchValue); - if (parsed && !isNaN(parsed)) { + const parsed = parseInt(searchValue, 10); + if (parsed && !Number.isNaN(parsed)) { return parsed; } } diff --git a/web/src/pages/overview/OverviewConnectedUsers/OverviewConnectedUsers.tsx b/web/src/pages/overview/OverviewConnectedUsers/OverviewConnectedUsers.tsx index f538a93b99..3995579170 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/OverviewConnectedUsers.tsx +++ b/web/src/pages/overview/OverviewConnectedUsers/OverviewConnectedUsers.tsx @@ -1,6 +1,6 @@ import './style.scss'; -import { NetworkUserStats } from '../../../shared/types'; +import type { NetworkUserStats } from '../../../shared/types'; import { UserConnectionCard } from './UserConnectionCard/UserConnectionCard'; interface Props { diff --git a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/UserConnectionCard.tsx index a4cd7eda54..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'; @@ -20,21 +20,21 @@ import { DeviceAvatar } from '../../../../shared/defguard-ui/components/Layout/D import { NetworkSpeed } from '../../../../shared/defguard-ui/components/Layout/NetworkSpeed/NetworkSpeed'; import { NetworkDirection } from '../../../../shared/defguard-ui/components/Layout/NetworkSpeed/types'; import { UserInitials } from '../../../../shared/defguard-ui/components/Layout/UserInitials/UserInitials'; +import { isPresent } from '../../../../shared/defguard-ui/utils/isPresent'; import { getUserFullName } from '../../../../shared/helpers/getUserFullName'; -import { +import type { NetworkDeviceStats, NetworkUserStats, StandaloneDeviceStats, } from '../../../../shared/types'; import { titleCase } from '../../../../shared/utils/titleCase'; import { - summarizeDevicesStats, summarizeDeviceStats, + summarizeDevicesStats, summarizeUsersNetworkStats, } from '../../helpers/stats'; import { NetworkUsageChart } from '../shared/components/NetworkUsageChart/NetworkUsageChart'; import { formatConnectionTime } from './formatConnectionTime'; -import { isPresent } from '../../../../shared/defguard-ui/utils/isPresent'; type DeviceConnectionCardProps = { data: StandaloneDeviceStats; @@ -242,6 +242,7 @@ const ConnectionTime = ({ connectedAt }: ConnectionTimeProps) => { return LL.common.noData(); }, [connectedAt, LL.common]); + // biome-ignore lint/correctness/useExhaustiveDependencies: intended useEffect(() => { const interval = 60 * 1000; const sub = timer(0, interval).subscribe(() => { diff --git a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx index d7b026f3fc..34cfcbbc76 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx +++ b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx @@ -19,7 +19,7 @@ import { NetworkSpeed } from '../../../../shared/defguard-ui/components/Layout/N import { NetworkDirection } from '../../../../shared/defguard-ui/components/Layout/NetworkSpeed/types'; import { UserInitials } from '../../../../shared/defguard-ui/components/Layout/UserInitials/UserInitials'; import { getUserFullName } from '../../../../shared/helpers/getUserFullName'; -import { NetworkDeviceStats, NetworkUserStats } from '../../../../shared/types'; +import type { NetworkDeviceStats, NetworkUserStats } from '../../../../shared/types'; import { summarizeDevicesStats } from '../../helpers/stats'; import { NetworkUsageChart } from '../shared/components/NetworkUsageChart/NetworkUsageChart'; diff --git a/web/src/pages/overview/OverviewConnectedUsers/shared/components/NetworkUsageChart/NetworkUsageChart.tsx b/web/src/pages/overview/OverviewConnectedUsers/shared/components/NetworkUsageChart/NetworkUsageChart.tsx index 83b54bc482..78cbe5a286 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/shared/components/NetworkUsageChart/NetworkUsageChart.tsx +++ b/web/src/pages/overview/OverviewConnectedUsers/shared/components/NetworkUsageChart/NetworkUsageChart.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { Bar, BarChart, XAxis, YAxis } from 'recharts'; import { ColorsRGB } from '../../../../../../shared/constants'; -import { NetworkDeviceStats } from '../../../../../../shared/types'; +import type { NetworkDeviceStats } from '../../../../../../shared/types'; import { parseStatsForCharts } from '../../../../helpers/stats'; interface NetworkUsageProps { diff --git a/web/src/pages/overview/OverviewExpandable/OverviewExpandable.tsx b/web/src/pages/overview/OverviewExpandable/OverviewExpandable.tsx index 1658791c7f..75e9455016 100644 --- a/web/src/pages/overview/OverviewExpandable/OverviewExpandable.tsx +++ b/web/src/pages/overview/OverviewExpandable/OverviewExpandable.tsx @@ -1,7 +1,7 @@ import './style.scss'; import clsx from 'clsx'; -import { ReactNode, useState } from 'react'; +import { type ReactNode, useState } from 'react'; type Props = { children?: ReactNode; diff --git a/web/src/pages/overview/OverviewHeader/OverviewNetworkSelect/OverviewNetworkSelect.tsx b/web/src/pages/overview/OverviewHeader/OverviewNetworkSelect/OverviewNetworkSelect.tsx index 81dffa5d44..0724e07d0b 100644 --- a/web/src/pages/overview/OverviewHeader/OverviewNetworkSelect/OverviewNetworkSelect.tsx +++ b/web/src/pages/overview/OverviewHeader/OverviewNetworkSelect/OverviewNetworkSelect.tsx @@ -4,7 +4,7 @@ import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../../i18n/i18n-react'; import { Select } from '../../../../shared/defguard-ui/components/Layout/Select/Select'; -import { +import type { SelectOption, SelectSelectedValue, } from '../../../../shared/defguard-ui/components/Layout/Select/types'; diff --git a/web/src/pages/overview/OverviewPage.tsx b/web/src/pages/overview/OverviewPage.tsx index 616a34f5ae..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 { @@ -47,6 +47,8 @@ export const OverviewPage = () => { queryKey: ['network'], queryFn: getNetworks, placeholderData: (perv) => perv, + select: (networks) => + orderBy(networks, (network) => network.name.toLowerCase(), ['asc']), }); const { data: networkStats } = useQuery({ @@ -58,7 +60,7 @@ export const OverviewPage = () => { }), refetchOnWindowFocus: false, refetchInterval: STATUS_REFETCH_TIMEOUT, - enabled: !isUndefined(selectedNetworkId) && !isNaN(selectedNetworkId), + enabled: !isUndefined(selectedNetworkId) && !Number.isNaN(selectedNetworkId), }); const { data: overviewStats, isLoading: userStatsLoading } = useQuery({ @@ -71,7 +73,7 @@ export const OverviewPage = () => { enabled: !isUndefined(statsFilter) && !isUndefined(selectedNetworkId) && - !isNaN(selectedNetworkId), + !Number.isNaN(selectedNetworkId), refetchOnWindowFocus: false, refetchInterval: STATUS_REFETCH_TIMEOUT, }); @@ -101,8 +103,9 @@ export const OverviewPage = () => { } }, [setOverViewStore, viewMode]); + // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater useEffect(() => { - if (isNaN(selectedNetworkId)) { + if (Number.isNaN(selectedNetworkId)) { navigate(`/admin/overview/${location.search}`, { replace: true, }); diff --git a/web/src/pages/overview/OverviewStats/OverviewStats.tsx b/web/src/pages/overview/OverviewStats/OverviewStats.tsx index e0b87a7c81..f4ebc26d88 100644 --- a/web/src/pages/overview/OverviewStats/OverviewStats.tsx +++ b/web/src/pages/overview/OverviewStats/OverviewStats.tsx @@ -3,7 +3,7 @@ import './style.scss'; import clsx from 'clsx'; import { orderBy } from 'lodash-es'; import millify from 'millify'; -import { forwardRef, ReactNode, useId, useMemo } from 'react'; +import { forwardRef, type ReactNode, useId, useMemo } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useI18nContext } from '../../../i18n/i18n-react'; @@ -13,8 +13,8 @@ import { Card } from '../../../shared/defguard-ui/components/Layout/Card/Card'; import { NetworkSpeed } from '../../../shared/defguard-ui/components/Layout/NetworkSpeed/NetworkSpeed'; import { NetworkDirection } from '../../../shared/defguard-ui/components/Layout/NetworkSpeed/types'; import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; -import { WireguardNetworkStats } from '../../../shared/types'; -import { useOverviewStore } from '../hooks/store/useOverviewStore'; +import type { WireguardNetworkStats } from '../../../shared/types'; +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/OverviewStats/utils.ts b/web/src/pages/overview/OverviewStats/utils.ts index ba4bbfaef7..fe9e648be6 100644 --- a/web/src/pages/overview/OverviewStats/utils.ts +++ b/web/src/pages/overview/OverviewStats/utils.ts @@ -1,6 +1,6 @@ import { groupBy, map, sortBy } from 'lodash-es'; -import { NetworkSpeedStats } from '../../../shared/types'; +import type { NetworkSpeedStats } from '../../../shared/types'; type AggregatedTick = { collected_at: string; diff --git a/web/src/pages/overview/OverviewStatsFilterSelect/OverviewStatsFilterSelect.tsx b/web/src/pages/overview/OverviewStatsFilterSelect/OverviewStatsFilterSelect.tsx deleted file mode 100644 index 390b64fa89..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 { - SelectOption, - 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 ( - diff --git a/web/src/pages/wizard/components/WizardNetworkConfiguration/components/DividerHeader.tsx b/web/src/pages/wizard/components/WizardNetworkConfiguration/components/DividerHeader.tsx new file mode 100644 index 0000000000..86243ea08b --- /dev/null +++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/components/DividerHeader.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren } from 'react'; + +type DividerHeaderProps = { + text: string; +} & PropsWithChildren; + +export const DividerHeader = ({ text, children }: DividerHeaderProps) => { + return ( +
+
+

{text}

+ {children} +
+
+ ); +}; diff --git a/web/src/pages/wizard/components/WizardNetworkConfiguration/style.scss b/web/src/pages/wizard/components/WizardNetworkConfiguration/style.scss index ffa278c0ff..675509ec13 100644 --- a/web/src/pages/wizard/components/WizardNetworkConfiguration/style.scss +++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/style.scss @@ -23,4 +23,33 @@ margin-bottom: 25px; } } + + #location-mfa-mode-explain-message-box { + ul { + list-style-position: inside; + margin-top: 8px; + + li { + p { + display: inline; + } + } + } + } + + .divider-header { + padding-bottom: var(--spacing-s); + + .inner { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + border-bottom: 1px solid var(--border-primary); + } + + .header { + @include typography(app-side-bar); + } + } } diff --git a/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx b/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx index 5e02ac77d2..52c798c0ee 100644 --- a/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx +++ b/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { isUndefined } from 'lodash-es'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { SubmitHandler, useForm } from 'react-hook-form'; +import { type SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { z } from 'zod'; import { shallow } from 'zustand/shallow'; @@ -19,12 +19,12 @@ import { } from '../../../../shared/defguard-ui/components/Layout/Button/types'; import { Card } from '../../../../shared/defguard-ui/components/Layout/Card/Card'; import { MessageBox } from '../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; -import { SelectOption } from '../../../../shared/defguard-ui/components/Layout/Select/types'; +import type { SelectOption } from '../../../../shared/defguard-ui/components/Layout/Select/types'; import useApi from '../../../../shared/hooks/useApi'; import { useToaster } from '../../../../shared/hooks/useToaster'; import { MutationKeys } from '../../../../shared/mutations'; import { QueryKeys } from '../../../../shared/queries'; -import { ImportNetworkRequest } from '../../../../shared/types'; +import type { ImportNetworkRequest } from '../../../../shared/types'; import { invalidateMultipleQueries } from '../../../../shared/utils/invalidateMultipleQueries'; import { titleCase } from '../../../../shared/utils/titleCase'; import { validateIpOrDomain } from '../../../../shared/validators'; @@ -65,13 +65,14 @@ export const WizardNetworkImport = () => { const zodSchema = useMemo( () => z.object({ - name: z.string().min(1, LL.form.error.required()), + name: z.string().trim().min(1, LL.form.error.required()), endpoint: z .string() + .trim() .min(1, LL.form.error.required()) .refine((val) => validateIpOrDomain(val), LL.form.error.endpoint()), - fileName: z.string().min(1, LL.form.error.required()), - config: z.string().min(1, LL.form.error.required()), + fileName: z.string().trim().min(1, LL.form.error.required()), + config: z.string().trim().min(1, LL.form.error.required()), allowed_groups: z.array(z.string().min(1, LL.form.error.minimumLength())), }), [LL.form.error], @@ -173,6 +174,7 @@ export const WizardNetworkImport = () => { queryFn: getGroups, }); + // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater useEffect(() => { if (fetchGroupsError) { toaster.error(LL.messages.error()); diff --git a/web/src/pages/wizard/components/WizardType/WizardType.tsx b/web/src/pages/wizard/components/WizardType/WizardType.tsx index b3977d14af..109c4bcdc4 100644 --- a/web/src/pages/wizard/components/WizardType/WizardType.tsx +++ b/web/src/pages/wizard/components/WizardType/WizardType.tsx @@ -20,7 +20,7 @@ export const WizardType = () => { const submitSubject = useWizardStore((state) => state.submitSubject); useEffect(() => { - if (submitSubject && submitSubject.subscribe) { + if (submitSubject?.subscribe) { const sub = submitSubject.subscribe(() => { nextStepSubject.next(); }); diff --git a/web/src/pages/wizard/components/WizardType/components/WizardTypeOptionCard/WizardTypeOptionCard.tsx b/web/src/pages/wizard/components/WizardType/components/WizardTypeOptionCard/WizardTypeOptionCard.tsx index dafe382138..c2a784ac68 100644 --- a/web/src/pages/wizard/components/WizardType/components/WizardTypeOptionCard/WizardTypeOptionCard.tsx +++ b/web/src/pages/wizard/components/WizardType/components/WizardTypeOptionCard/WizardTypeOptionCard.tsx @@ -1,6 +1,6 @@ import './style.scss'; -import { ReactNode } from 'react'; +import type { ReactNode } from 'react'; import { useI18nContext } from '../../../../../../i18n/i18n-react'; import IconCheckmarkWhite from '../../../../../../shared/components/svg/IconCheckmarkWhite'; diff --git a/web/src/pages/wizard/hooks/useWizardStore.ts b/web/src/pages/wizard/hooks/useWizardStore.ts index d4508c27cf..0206902238 100644 --- a/web/src/pages/wizard/hooks/useWizardStore.ts +++ b/web/src/pages/wizard/hooks/useWizardStore.ts @@ -3,11 +3,15 @@ import { Subject } from 'rxjs'; import { createJSONStorage, persist } from 'zustand/middleware'; import { createWithEqualityFn } from 'zustand/traditional'; -import { ImportedDevice, Network } from '../../../shared/types'; +import { + type ImportedDevice, + LocationMfaMode, + type Network, +} from '../../../shared/types'; export enum WizardSetupType { - 'IMPORT' = 'IMPORT', - 'MANUAL' = 'MANUAL', + IMPORT = 'IMPORT', + MANUAL = 'MANUAL', } const defaultValues: StoreFields = { @@ -25,11 +29,11 @@ const defaultValues: StoreFields = { allowed_ips: '', allowed_groups: [], dns: '', - mfa_enabled: false, keepalive_interval: 25, - peer_disconnect_threshold: 180, + peer_disconnect_threshold: 300, acl_enabled: false, acl_default_allow: false, + location_mfa_mode: LocationMfaMode.DISABLED, }, }; @@ -81,11 +85,11 @@ type StoreFields = { allowed_ips: string; allowed_groups: string[]; dns?: string; - mfa_enabled: boolean; keepalive_interval: number; peer_disconnect_threshold: number; acl_enabled: boolean; acl_default_allow: boolean; + location_mfa_mode: LocationMfaMode; }; }; diff --git a/web/src/shared/components/Form/FormAclDefaultPolicySelect/FormAclDefaultPolicy.tsx b/web/src/shared/components/Form/FormAclDefaultPolicySelect/FormAclDefaultPolicy.tsx index 87d60c04ee..d3eef4a19b 100644 --- a/web/src/shared/components/Form/FormAclDefaultPolicySelect/FormAclDefaultPolicy.tsx +++ b/web/src/shared/components/Form/FormAclDefaultPolicySelect/FormAclDefaultPolicy.tsx @@ -1,9 +1,9 @@ import { useMemo } from 'react'; -import { FieldValues, UseControllerProps } from 'react-hook-form'; +import type { FieldValues, UseControllerProps } from 'react-hook-form'; import { useI18nContext } from '../../../../i18n/i18n-react'; import { FormSelect } from '../../../defguard-ui/components/Form/FormSelect/FormSelect'; -import { SelectOption } from '../../../defguard-ui/components/Layout/Select/types'; +import type { SelectOption } from '../../../defguard-ui/components/Layout/Select/types'; import { useAppStore } from '../../../hooks/store/useAppStore'; type Props = { diff --git a/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx b/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx new file mode 100644 index 0000000000..0c87aae1c7 --- /dev/null +++ b/web/src/shared/components/Form/FormLocationMfaModeSelect/FormLocationMfaModeSelect.tsx @@ -0,0 +1,82 @@ +import './style.scss'; +import clsx from 'clsx'; +import { useMemo } from 'react'; +import { + type FieldValues, + type UseControllerProps, + useController, +} from 'react-hook-form'; +import { useI18nContext } from '../../../../i18n/i18n-react'; +import { RadioButton } from '../../../defguard-ui/components/Layout/RadioButton/Radiobutton'; +import type { SelectOption } from '../../../defguard-ui/components/Layout/Select/types'; +import { useAppStore } from '../../../hooks/store/useAppStore'; +import { LocationMfaMode } from '../../../types'; + +type Props = { + controller: UseControllerProps; +}; + +export const FormLocationMfaModeSelect = ({ + controller, +}: Props) => { + const { LL } = useI18nContext(); + const { + field: { onChange, value: fieldValue }, + } = useController(controller); + const enterpriseEnabled = useAppStore((s) => s.appInfo?.license_info.enterprise); + const externalOpenIdConfigured = useAppStore((s) => s.appInfo?.external_openid_enabled); + const externalMfaDisabled = !(enterpriseEnabled && externalOpenIdConfigured); + + const options = useMemo( + (): SelectOption[] => [ + { + key: LocationMfaMode.DISABLED, + value: LocationMfaMode.DISABLED, + label: LL.components.locationMfaModeSelect.options.disabled(), + }, + { + key: LocationMfaMode.INTERNAL, + value: LocationMfaMode.INTERNAL, + label: LL.components.locationMfaModeSelect.options.internal(), + }, + { + key: LocationMfaMode.EXTERNAL, + value: LocationMfaMode.EXTERNAL, + label: LL.components.locationMfaModeSelect.options.external(), + disabled: externalMfaDisabled, + }, + ], + [ + LL.components.locationMfaModeSelect.options.disabled, + LL.components.locationMfaModeSelect.options.external, + LL.components.locationMfaModeSelect.options.internal, + externalMfaDisabled, + ], + ); + + return ( +
+ + {options.map(({ key, value, label, disabled = false }) => { + const active = fieldValue === value; + return ( +
{ + if (!disabled) { + onChange(value); + } + }} + > +

{label}

+ +
+ ); + })} +
+ ); +}; diff --git a/web/src/shared/components/Form/FormLocationMfaModeSelect/style.scss b/web/src/shared/components/Form/FormLocationMfaModeSelect/style.scss new file mode 100644 index 0000000000..f61984de9c --- /dev/null +++ b/web/src/shared/components/Form/FormLocationMfaModeSelect/style.scss @@ -0,0 +1,59 @@ +.location-mfa-mode-select { + display: flex; + flex-flow: column; + row-gap: var(--spacing-s); + margin-bottom: 25px; + + .location-mfa-mode { + display: flex; + align-items: center; + justify-content: space-between; + column-gap: var(--spacing-xs); + min-height: 30px; + border: 1px solid var(--border-primary); + padding: var(--spacing-xs) var(--spacing-s); + border-radius: 10px; + cursor: pointer; + user-select: none; + transition-property: border-color, opacity; + @include animate-standard; + + &:not(.active) { + &:hover { + border-color: var(--border-separator); + } + } + + &.active { + border-color: var(--surface-main-primary); + } + + &.active, + &:hover { + .label { + color: var(--text-body-primary); + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: var(--surface-secondary); + + .label { + color: var(--text-body-disabled); + } + + &:hover { + border-color: var(--border-primary); + } + } + + .label { + color: var(--text-body-secondary); + transition-property: color; + @include typography(app-modal-1); + @include animate-standard; + } + } +} diff --git a/web/src/shared/components/Layout/DateInput/DateInput.tsx b/web/src/shared/components/Layout/DateInput/DateInput.tsx index 9ac05ce806..150913b794 100644 --- a/web/src/shared/components/Layout/DateInput/DateInput.tsx +++ b/web/src/shared/components/Layout/DateInput/DateInput.tsx @@ -2,14 +2,14 @@ import './style.scss'; import clsx from 'clsx'; import dayjs from 'dayjs'; -import { forwardRef, HTMLAttributes } from 'react'; -import DatePicker, { ReactDatePickerCustomHeaderProps } from 'react-datepicker'; +import { forwardRef, type HTMLAttributes } from 'react'; +import DatePicker, { type ReactDatePickerCustomHeaderProps } from 'react-datepicker'; import { FieldError } from '../../../defguard-ui/components/Layout/FieldError/FieldError'; import { InteractionBox } from '../../../defguard-ui/components/Layout/InteractionBox/InteractionBox'; import SvgIconX from '../../../defguard-ui/components/svg/IconX'; import { isPresent } from '../../../defguard-ui/utils/isPresent'; -import { DateInputProps } from './types'; +import type { DateInputProps } from './types'; const pickerToOutput = (value: Date | null): string | null => { if (value === null) return null; diff --git a/web/src/shared/components/Layout/DateInput/FormDateInput.tsx b/web/src/shared/components/Layout/DateInput/FormDateInput.tsx index 4c10051842..7163f889d3 100644 --- a/web/src/shared/components/Layout/DateInput/FormDateInput.tsx +++ b/web/src/shared/components/Layout/DateInput/FormDateInput.tsx @@ -1,9 +1,13 @@ import { isUndefined } from 'lodash-es'; import { useMemo } from 'react'; -import { FieldValues, useController, UseControllerProps } from 'react-hook-form'; +import { + type FieldValues, + type UseControllerProps, + useController, +} from 'react-hook-form'; import { DateInput } from './DateInput'; -import { DateInputProps } from './types'; +import type { DateInputProps } from './types'; type Props = { onChange?: (value: string | null) => void; diff --git a/web/src/shared/components/Layout/EnterpriseUpgradeToast/EnterpriseUpgradeToast.tsx b/web/src/shared/components/Layout/EnterpriseUpgradeToast/EnterpriseUpgradeToast.tsx index 3d66d0e1a1..5e557bbe9d 100644 --- a/web/src/shared/components/Layout/EnterpriseUpgradeToast/EnterpriseUpgradeToast.tsx +++ b/web/src/shared/components/Layout/EnterpriseUpgradeToast/EnterpriseUpgradeToast.tsx @@ -5,7 +5,7 @@ import { useCallback } from 'react'; import { useI18nContext } from '../../../../i18n/i18n-react'; import { Badge } from '../../../defguard-ui/components/Layout/Badge/Badge'; import { BadgeStyleVariant } from '../../../defguard-ui/components/Layout/Badge/types'; -import { ToastOptions } from '../../../defguard-ui/components/Layout/ToastManager/Toast/types'; +import type { ToastOptions } from '../../../defguard-ui/components/Layout/ToastManager/Toast/types'; import { useToastsStore } from '../../../defguard-ui/hooks/toasts/useToastStore'; import SvgIconX from '../../svg/IconX'; diff --git a/web/src/shared/components/Layout/EnterpriseUpgradeToast/types.ts b/web/src/shared/components/Layout/EnterpriseUpgradeToast/types.ts index 6a646624e9..5e111a0ca7 100644 --- a/web/src/shared/components/Layout/EnterpriseUpgradeToast/types.ts +++ b/web/src/shared/components/Layout/EnterpriseUpgradeToast/types.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const enterpriseUpgradeToastMetaSchema = z.object({ - customId: z.string().min(1), + customId: z.string().trim().min(1), }); export type EnterpriseUpgradeToastMeta = z.infer; diff --git a/web/src/shared/components/Layout/ExpandableSection/ExpandableSection.tsx b/web/src/shared/components/Layout/ExpandableSection/ExpandableSection.tsx index 71a18e7287..4178b75c19 100644 --- a/web/src/shared/components/Layout/ExpandableSection/ExpandableSection.tsx +++ b/web/src/shared/components/Layout/ExpandableSection/ExpandableSection.tsx @@ -1,7 +1,7 @@ import './style.scss'; import clsx from 'clsx'; -import { PropsWithChildren, useState } from 'react'; +import { type PropsWithChildren, useState } from 'react'; import { ArrowSingle } from '../../../defguard-ui/components/icons/ArrowSingle/ArrowSingle'; import { ArrowSingleDirection } from '../../../defguard-ui/components/icons/ArrowSingle/types'; diff --git a/web/src/shared/components/Layout/ListCellTags/ListCellTags.tsx b/web/src/shared/components/Layout/ListCellTags/ListCellTags.tsx index bd2ee115a4..2fcbabc29a 100644 --- a/web/src/shared/components/Layout/ListCellTags/ListCellTags.tsx +++ b/web/src/shared/components/Layout/ListCellTags/ListCellTags.tsx @@ -4,7 +4,7 @@ import useResizeObserver from '@react-hook/resize-observer'; import clsx from 'clsx'; import { useCallback, useRef, useState } from 'react'; -import { ListCellTag } from '../../../../pages/acl/AclIndexPage/components/shared/types'; +import type { ListCellTag } from '../../../../pages/acl/AclIndexPage/components/shared/types'; import { FloatingMenu } from '../../../defguard-ui/components/Layout/FloatingMenu/FloatingMenu'; import { FloatingMenuProvider } from '../../../defguard-ui/components/Layout/FloatingMenu/FloatingMenuProvider'; import { FloatingMenuTrigger } from '../../../defguard-ui/components/Layout/FloatingMenu/FloatingMenuTrigger'; diff --git a/web/src/shared/components/Layout/ListCellText/ListCellText.tsx b/web/src/shared/components/Layout/ListCellText/ListCellText.tsx deleted file mode 100644 index ed12bb443e..0000000000 --- a/web/src/shared/components/Layout/ListCellText/ListCellText.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import './style.scss'; - -import useResizeObserver from '@react-hook/resize-observer'; -import clsx from 'clsx'; -import { useCallback, useRef, useState } from 'react'; - -import { ActionButton } from '../../../defguard-ui/components/Layout/ActionButton/ActionButton'; -import { ActionButtonVariant } from '../../../defguard-ui/components/Layout/ActionButton/types'; -import { FloatingMenu } from '../../../defguard-ui/components/Layout/FloatingMenu/FloatingMenu'; -import { FloatingMenuProvider } from '../../../defguard-ui/components/Layout/FloatingMenu/FloatingMenuProvider'; -import { FloatingMenuTrigger } from '../../../defguard-ui/components/Layout/FloatingMenu/FloatingMenuTrigger'; -import { useClipboard } from '../../../hooks/useClipboard'; - -type Props = { - text: string; - withCopy?: boolean; -}; - -export const ListCellText = ({ text, withCopy }: Props) => { - const containerRef = useRef(null); - const [overflows, setOverflows] = useState(false); - - const { writeToClipboard } = useClipboard(); - - const handleResize = useCallback(() => { - if (containerRef.current) { - setOverflows(containerRef.current.scrollWidth > containerRef.current.clientWidth); - } - }, []); - - useResizeObserver(containerRef, handleResize); - return ( - -
- -

{text}

-
-
- -

{text}

- {withCopy && ( - { - void writeToClipboard(text); - }} - /> - )} -
-
- ); -}; diff --git a/web/src/shared/components/Layout/ListCellText/style.scss b/web/src/shared/components/Layout/ListCellText/style.scss deleted file mode 100644 index eedad75778..0000000000 --- a/web/src/shared/components/Layout/ListCellText/style.scss +++ /dev/null @@ -1,37 +0,0 @@ -.list-cell-text { - width: 100%; - max-width: 100%; - overflow: hidden; - - p { - display: inline-block; - text-wrap: nowrap; - text-overflow: ellipsis; - white-space: nowrap; - } - - &.overflows { - p { - cursor: help; - } - } -} - -.list-cell-text-floating { - max-width: min(480px, 90dvw); - - p { - @include typography(app-modal-1); - color: var(--text-body-primary); - } - - &.copy { - .floating-menu-inner { - display: grid; - grid-template-rows: 1fr; - grid-template-columns: 1fr 40px; - column-gap: var(--spacing-xs); - align-items: center; - } - } -} diff --git a/web/src/shared/components/Layout/ListHeader/ListHeader.tsx b/web/src/shared/components/Layout/ListHeader/ListHeader.tsx index 376f58bdb5..54a28ab92a 100644 --- a/web/src/shared/components/Layout/ListHeader/ListHeader.tsx +++ b/web/src/shared/components/Layout/ListHeader/ListHeader.tsx @@ -8,7 +8,7 @@ import { CheckBox } from '../../../defguard-ui/components/Layout/Checkbox/CheckB import { InteractionBox } from '../../../defguard-ui/components/Layout/InteractionBox/InteractionBox'; import { ListSortDirection } from '../../../defguard-ui/components/Layout/VirtualizedList/types'; import { isPresent } from '../../../defguard-ui/utils/isPresent'; -import { ListHeaderColumnConfig } from './types'; +import type { ListHeaderColumnConfig } from './types'; type ListHeaderColumnProps = { active: boolean; diff --git a/web/src/shared/components/Layout/ManagementPageLayout/ManagementPageLayout.tsx b/web/src/shared/components/Layout/ManagementPageLayout/ManagementPageLayout.tsx index 04b99a931d..6a8ec02173 100644 --- a/web/src/shared/components/Layout/ManagementPageLayout/ManagementPageLayout.tsx +++ b/web/src/shared/components/Layout/ManagementPageLayout/ManagementPageLayout.tsx @@ -4,7 +4,7 @@ import { clsx } from 'clsx'; import { useNavigationStore } from '../../../../components/Navigation/hooks/useNavigationStore'; import { Search } from '../../../defguard-ui/components/Layout/Search/Search'; -import { ManagementPageProps } from './types'; +import type { ManagementPageProps } from './types'; export const ManagementPageLayout = ({ children, diff --git a/web/src/shared/components/Layout/ManagementPageLayout/types.ts b/web/src/shared/components/Layout/ManagementPageLayout/types.ts index a867bd10b5..51d0ac0542 100644 --- a/web/src/shared/components/Layout/ManagementPageLayout/types.ts +++ b/web/src/shared/components/Layout/ManagementPageLayout/types.ts @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import type { ReactNode } from 'react'; export type ManagementPageProps = { children: ReactNode; diff --git a/web/src/shared/components/Layout/PageContainer/PageContainer.tsx b/web/src/shared/components/Layout/PageContainer/PageContainer.tsx index 0aa6fff0a2..523f603663 100644 --- a/web/src/shared/components/Layout/PageContainer/PageContainer.tsx +++ b/web/src/shared/components/Layout/PageContainer/PageContainer.tsx @@ -1,22 +1,31 @@ import './style.scss'; -import classNames from 'classnames'; -import { ComponentPropsWithoutRef, forwardRef, useMemo } from 'react'; - +import clsx from 'clsx'; +import type { ComponentProps } from 'react'; import { useNavigationStore } from '../../../../components/Navigation/hooks/useNavigationStore'; -export const PageContainer = forwardRef>( - ({ children, className, ...rest }, ref) => { - const isNavOpen = useNavigationStore((state) => state.isOpen); - const cn = useMemo(() => classNames('page-container', className), [className]); - const contentCn = useMemo( - () => classNames('page-content', { 'nav-open': isNavOpen }), - [isNavOpen], - ); - return ( -
-
{children}
+type Props = { + withDefaultPadding?: boolean; +} & ComponentProps<'div'>; + +export const PageContainer = ({ + children, + className, + ref, + withDefaultPadding = false, + ...rest +}: Props) => { + const isNavOpen = useNavigationStore((state) => state.isOpen); + return ( +
+
+ {children}
- ); - }, -); +
+ ); +}; diff --git a/web/src/shared/components/Layout/PageContainer/style.scss b/web/src/shared/components/Layout/PageContainer/style.scss index 591137583c..2c1b17be20 100644 --- a/web/src/shared/components/Layout/PageContainer/style.scss +++ b/web/src/shared/components/Layout/PageContainer/style.scss @@ -1,27 +1,13 @@ @use '@scssutils' as *; .page-container { + min-height: inherit; + @include media-breakpoint-down(lg) { height: 100%; max-height: inherit; box-sizing: border-box; position: relative; - - & > .page-content { - overflow-y: auto; - box-sizing: border-box; - margin-top: 6rem; - height: calc(100% - 6rem); - max-height: 100%; - - // hide scroll - &::-webkit-scrollbar { - display: none; - } - - -ms-overflow-style: none; - scrollbar-width: none; /* Firefox */ - } } @include media-breakpoint-up(lg) { @@ -33,9 +19,6 @@ & > .page-content { width: calc(100% - 87px); margin-left: 87px; - height: inherit; - max-height: inherit; - position: relative; &.nav-open { margin-left: 230px; @@ -44,3 +27,12 @@ } } } + +.page-container > .page-content.default-padding { + box-sizing: border-box; + padding: var(--spacing-m) var(--spacing-s) var(--spacing-m); + + @include media-breakpoint-up(lg) { + padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-m); + } +} diff --git a/web/src/shared/components/Layout/PageLayout/PageLayout.tsx b/web/src/shared/components/Layout/PageLayout/PageLayout.tsx index c081ee14c0..b8c4880a52 100644 --- a/web/src/shared/components/Layout/PageLayout/PageLayout.tsx +++ b/web/src/shared/components/Layout/PageLayout/PageLayout.tsx @@ -1,18 +1,28 @@ import './style.scss'; import clsx from 'clsx'; -import { PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; import { PageContainer } from '../PageContainer/PageContainer'; type Props = { id: string; className?: string; + withDefaultPadding?: boolean; } & PropsWithChildren; -export const PageLayout = ({ id, className, children }: Props) => { +export const PageLayout = ({ + id, + className, + children, + withDefaultPadding = false, +}: Props) => { return ( - + {children} ); diff --git a/web/src/shared/components/Layout/PageLimiter/PageLimiter.tsx b/web/src/shared/components/Layout/PageLimiter/PageLimiter.tsx index 8146dfe8af..c34347a1f8 100644 --- a/web/src/shared/components/Layout/PageLimiter/PageLimiter.tsx +++ b/web/src/shared/components/Layout/PageLimiter/PageLimiter.tsx @@ -1,7 +1,7 @@ import './style.scss'; import clsx from 'clsx'; -import { HTMLAttributes, PropsWithChildren } from 'react'; +import type { HTMLAttributes, PropsWithChildren } from 'react'; import { useNavigationStore } from '../../../../components/Navigation/hooks/useNavigationStore'; diff --git a/web/src/shared/components/Layout/SectionWithCard/SectionWithCard.tsx b/web/src/shared/components/Layout/SectionWithCard/SectionWithCard.tsx index d773c50298..b49170f7a0 100644 --- a/web/src/shared/components/Layout/SectionWithCard/SectionWithCard.tsx +++ b/web/src/shared/components/Layout/SectionWithCard/SectionWithCard.tsx @@ -1,7 +1,7 @@ import './style.scss'; import clsx from 'clsx'; -import { HTMLAttributes, PropsWithChildren } from 'react'; +import type { HTMLAttributes, PropsWithChildren } from 'react'; import { Card } from '../../../defguard-ui/components/Layout/Card/Card'; diff --git a/web/src/shared/components/Layout/UpgradeLicenseModal/UpgradeLicenseModal.tsx b/web/src/shared/components/Layout/UpgradeLicenseModal/UpgradeLicenseModal.tsx index f96b273524..b1347d232a 100644 --- a/web/src/shared/components/Layout/UpgradeLicenseModal/UpgradeLicenseModal.tsx +++ b/web/src/shared/components/Layout/UpgradeLicenseModal/UpgradeLicenseModal.tsx @@ -62,7 +62,7 @@ const ModalContent = () => { className="content" style={{ // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore + //@ts-expect-error '--list-image': `url("${checkboxUrl}")`, }} > diff --git a/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx b/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx index 2eb3c8208c..79d40db00e 100644 --- a/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx +++ b/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx @@ -4,7 +4,7 @@ import dayjs from 'dayjs'; import { useCallback, useEffect } from 'react'; import { useI18nContext } from '../../../../i18n/i18n-react'; -import { ToastOptions } from '../../../defguard-ui/components/Layout/ToastManager/Toast/types'; +import type { ToastOptions } from '../../../defguard-ui/components/Layout/ToastManager/Toast/types'; import { useToastsStore } from '../../../defguard-ui/hooks/toasts/useToastStore'; import { useUpdatesStore } from '../../../hooks/store/useUpdatesStore'; diff --git a/web/src/shared/components/Layout/VersionUpdateToast/types.ts b/web/src/shared/components/Layout/VersionUpdateToast/types.ts index e4a251bc58..8ea128bf9a 100644 --- a/web/src/shared/components/Layout/VersionUpdateToast/types.ts +++ b/web/src/shared/components/Layout/VersionUpdateToast/types.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const versionUpdateToastMetaSchema = z.object({ - customId: z.string().min(1), + customId: z.string().trim().min(1), }); export type VersionUpdateToastMeta = z.infer; diff --git a/web/src/shared/components/Layout/WireguardConfigExpandable/WireguardConfigExpandable.tsx b/web/src/shared/components/Layout/WireguardConfigExpandable/WireguardConfigExpandable.tsx index 6747c8bb14..998b026f19 100644 --- a/web/src/shared/components/Layout/WireguardConfigExpandable/WireguardConfigExpandable.tsx +++ b/web/src/shared/components/Layout/WireguardConfigExpandable/WireguardConfigExpandable.tsx @@ -1,6 +1,6 @@ import './style.scss'; -import { Fragment, ReactNode, useCallback, useMemo, useState } from 'react'; +import { Fragment, type ReactNode, useCallback, useMemo, useState } from 'react'; import QRCode from 'react-qr-code'; import { useI18nContext } from '../../../../i18n/i18n-react'; diff --git a/web/src/shared/components/Layout/buttons/OpenDesktopClientButton/OpenDesktopClientButton.tsx b/web/src/shared/components/Layout/buttons/OpenDesktopClientButton/OpenDesktopClientButton.tsx new file mode 100644 index 0000000000..f7cc9b84dd --- /dev/null +++ b/web/src/shared/components/Layout/buttons/OpenDesktopClientButton/OpenDesktopClientButton.tsx @@ -0,0 +1,30 @@ +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { Button } from '../../../../defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../defguard-ui/components/Layout/Button/types'; + +type Props = { + url: string; + token: string; + customMessage?: string; +}; + +export const OpenDesktopClientButton = ({ token, url, customMessage }: Props) => { + const { LL } = useI18nContext(); + const makeUrl = () => { + return `defguard://addinstance?token=${token}&url=${url}`; + }; + + return ( + +
+
+
+ ); +}; diff --git a/web/src/shared/components/modals/OutdatedComponentsModal/style.scss b/web/src/shared/components/modals/OutdatedComponentsModal/style.scss new file mode 100644 index 0000000000..2460784f76 --- /dev/null +++ b/web/src/shared/components/modals/OutdatedComponentsModal/style.scss @@ -0,0 +1,145 @@ +#outdated-components-modal { + max-width: 100%; + overflow-x: hidden; + + @include media-breakpoint-up(lg) { + background-color: transparent; + box-shadow: var(--box-shadow); + } + + .content-wrapper { + background-color: var(--surface-nav-bg); + border-radius: 0; + + @include media-breakpoint-down(lg) { + display: flex; + flex-flow: column; + align-items: center; + justify-content: flex-start; + width: 100%; + box-sizing: border-box; + padding: 20px 20px 40px; + row-gap: 30px; + } + + @include media-breakpoint-up(lg) { + background-color: var(--surface-main-primary); + border-radius: 15px; + } + + & > .top { + box-sizing: border-box; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + row-gap: 10px; + background-color: var(--surface-main-primary); + width: 100%; + padding: 20px; + + @include media-breakpoint-down(lg) { + border-radius: 15px; + min-width: 280px; + max-width: calc(100% - 40px); + } + + @include media-breakpoint-up(md) { + padding: 50px; + } + + @include media-breakpoint-up(lg) { + background-color: transparent; + padding: 56px 20px; + border-top-left-radius: 15px; + border-top-right-radius: 15px; + } + + h1, + h3 { + color: var(--text-button-secondary); + } + + h1 { + @include typography(app-title); + } + + h3 { + @include typography(app-welcome-2); + } + } + + & > .bottom { + background-color: var(--surface-nav-bg); + + @include media-breakpoint-up(lg) { + border-radius: 15px; + padding: 30px 50px 50px; + } + + & > .content { + padding-bottom: 40px; + display: flex; + flex-flow: column; + row-gap: 20px; + + h2 { + @include typography(app-welcome-2); + } + + ul { + padding-left: 20px; + box-sizing: border-box; + } + + p, + span, + a, + li { + @include typography(app-body-2); + } + + li > div { + display: flex; + flex-flow: row; + align-items: center; + justify-content: flex-start; + gap: 5px; + } + + .version { + color: var(--text-alert); + @include typography(app-copyright); + } + } + + .controls { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-flow: row; + + .btn { + max-height: 47px; + max-width: 400px; + width: 100%; + } + } + } + } +} + +// modal container setup +.modal-root { + .modal.outdated-components-modal { + min-height: 100dvh; + + @include media-breakpoint-up(lg) { + grid-template-columns: max(920px); + padding-top: 200px; + justify-content: center; + align-items: start; + } + } +} diff --git a/web/src/shared/components/modals/OutdatedComponentsModal/useOutdatedComponentsModal.tsx b/web/src/shared/components/modals/OutdatedComponentsModal/useOutdatedComponentsModal.tsx new file mode 100644 index 0000000000..6b1c9116a5 --- /dev/null +++ b/web/src/shared/components/modals/OutdatedComponentsModal/useOutdatedComponentsModal.tsx @@ -0,0 +1,27 @@ +import { createWithEqualityFn } from 'zustand/traditional'; +import type { OutdatedComponents } from '../../../types'; + +const defaultValues: StoreValues = { + componentsInfo: { gateways: [] }, + visible: false, +}; + +export const useOutdatedComponentsModal = createWithEqualityFn((set) => ({ + ...defaultValues, + close: () => set({ visible: false }), + open: (data) => set({ visible: true, componentsInfo: data }), + reset: () => set(defaultValues), +})); + +type Store = StoreMethods & StoreValues; + +type StoreMethods = { + open: (initData: OutdatedComponents) => void; + close: () => void; + reset: () => void; +}; + +type StoreValues = { + visible: boolean; + componentsInfo: OutdatedComponents; +}; diff --git a/web/src/shared/components/modals/UpdateNotificationModal/UpdateNotificationModal.tsx b/web/src/shared/components/modals/UpdateNotificationModal/UpdateNotificationModal.tsx index 4646738b07..d320b26d7a 100644 --- a/web/src/shared/components/modals/UpdateNotificationModal/UpdateNotificationModal.tsx +++ b/web/src/shared/components/modals/UpdateNotificationModal/UpdateNotificationModal.tsx @@ -1,16 +1,15 @@ // eslint-disable-next-line simple-import-sort/imports import { shallow } from 'zustand/shallow'; - -import { Modal } from '../../../defguard-ui/components/Layout/modals/Modal/Modal'; -import { useUpdatesStore } from '../../../hooks/store/useUpdatesStore'; -import { UpdateNotificationModalIcons } from './components/UpdateNotificationModalIcons'; +import { useI18nContext } from '../../../../i18n/i18n-react'; import { Button } from '../../../defguard-ui/components/Layout/Button/Button'; import { ButtonSize, ButtonStyleVariant, } from '../../../defguard-ui/components/Layout/Button/types'; -import { useI18nContext } from '../../../../i18n/i18n-react'; +import { Modal } from '../../../defguard-ui/components/Layout/modals/Modal/Modal'; +import { useUpdatesStore } from '../../../hooks/store/useUpdatesStore'; import { RenderMarkdown } from '../../Layout/RenderMarkdown/RenderMarkdown'; +import { UpdateNotificationModalIcons } from './components/UpdateNotificationModalIcons'; import './style.scss'; import dayjs from 'dayjs'; diff --git a/web/src/shared/components/network/DeviceConfigsCard/DeviceConfigsCard.tsx b/web/src/shared/components/network/DeviceConfigsCard/DeviceConfigsCard.tsx index 219c9aa71b..448142b50a 100644 --- a/web/src/shared/components/network/DeviceConfigsCard/DeviceConfigsCard.tsx +++ b/web/src/shared/components/network/DeviceConfigsCard/DeviceConfigsCard.tsx @@ -12,15 +12,15 @@ import { ExpandableCard } from '../../../defguard-ui/components/Layout/Expandabl import { LoaderSpinner } from '../../../defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; import { Select } from '../../../defguard-ui/components/Layout/Select/Select'; import { - SelectOption, - SelectSelectedValue, + type SelectOption, + type SelectSelectedValue, SelectSizeVariant, } from '../../../defguard-ui/components/Layout/Select/types'; import useApi from '../../../hooks/useApi'; import { useClipboard } from '../../../hooks/useClipboard'; import { QueryKeys } from '../../../queries'; import { downloadWGConfig } from '../../../utils/downloadWGConfig'; -import { DeviceConfigsCardNetworkInfo } from './types'; +import type { DeviceConfigsCardNetworkInfo } from './types'; type Props = { deviceId: number; diff --git a/web/src/shared/components/network/GatewaysStatus/AllNetworksGatewaysStatus/AllNetworksGatewaysStatus.tsx b/web/src/shared/components/network/GatewaysStatus/AllNetworksGatewaysStatus/AllNetworksGatewaysStatus.tsx index f6bf59195d..bac392540d 100644 --- a/web/src/shared/components/network/GatewaysStatus/AllNetworksGatewaysStatus/AllNetworksGatewaysStatus.tsx +++ b/web/src/shared/components/network/GatewaysStatus/AllNetworksGatewaysStatus/AllNetworksGatewaysStatus.tsx @@ -7,7 +7,7 @@ import { useEffect, useMemo } from 'react'; import { isPresent } from '../../../../defguard-ui/utils/isPresent'; import useApi from '../../../../hooks/useApi'; import { useToaster } from '../../../../hooks/useToaster'; -import { GatewayStatus } from '../../../../types'; +import type { GatewayStatus } from '../../../../types'; import { GatewaysFloatingStatus } from '../GatewaysFloatingStatus/GatewaysFloatingStatus'; import { GatewaysStatusInfo } from '../GatewaysStatusInfo/GatewaysStatusInfo'; diff --git a/web/src/shared/components/network/GatewaysStatus/GatewayStatusIcon.tsx b/web/src/shared/components/network/GatewaysStatus/GatewayStatusIcon.tsx index 9d0ad19b25..88c2e9a11c 100644 --- a/web/src/shared/components/network/GatewaysStatus/GatewayStatusIcon.tsx +++ b/web/src/shared/components/network/GatewaysStatus/GatewayStatusIcon.tsx @@ -1,4 +1,4 @@ -import { motion, TargetAndTransition } from 'framer-motion'; +import { motion, type TargetAndTransition } from 'motion/react'; import { useMemo } from 'react'; import { ColorsRGB } from '../../../constants'; diff --git a/web/src/shared/components/network/GatewaysStatus/GatewaysFloatingStatus/GatewaysFloatingStatus.tsx b/web/src/shared/components/network/GatewaysStatus/GatewaysFloatingStatus/GatewaysFloatingStatus.tsx index 94e5f5dbb8..aff48c4641 100644 --- a/web/src/shared/components/network/GatewaysStatus/GatewaysFloatingStatus/GatewaysFloatingStatus.tsx +++ b/web/src/shared/components/network/GatewaysStatus/GatewaysFloatingStatus/GatewaysFloatingStatus.tsx @@ -5,7 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { InteractionBox } from '../../../../defguard-ui/components/Layout/InteractionBox/InteractionBox'; import useApi from '../../../../hooks/useApi'; import { useToaster } from '../../../../hooks/useToaster'; -import { GatewayStatus } from '../../../../types'; +import type { GatewayStatus } from '../../../../types'; type Props = { status: GatewayStatus; diff --git a/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/GatewaysStatusInfo.tsx b/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/GatewaysStatusInfo.tsx index 8742514a39..ab42200e1b 100644 --- a/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/GatewaysStatusInfo.tsx +++ b/web/src/shared/components/network/GatewaysStatus/GatewaysStatusInfo/GatewaysStatusInfo.tsx @@ -1,7 +1,7 @@ import './style.scss'; import clsx from 'clsx'; -import { PropsWithChildren, useMemo, useState } from 'react'; +import { type PropsWithChildren, useMemo, useState } from 'react'; import Skeleton from 'react-loading-skeleton'; import { useI18nContext } from '../../../../../i18n/i18n-react'; diff --git a/web/src/shared/components/network/GatewaysStatus/NetworkGatewaysStatus/NetworkGatewaysStatus.tsx b/web/src/shared/components/network/GatewaysStatus/NetworkGatewaysStatus/NetworkGatewaysStatus.tsx index 72072fab1a..345d47d3da 100644 --- a/web/src/shared/components/network/GatewaysStatus/NetworkGatewaysStatus/NetworkGatewaysStatus.tsx +++ b/web/src/shared/components/network/GatewaysStatus/NetworkGatewaysStatus/NetworkGatewaysStatus.tsx @@ -6,7 +6,7 @@ import { GatewaysFloatingStatus } from '../GatewaysFloatingStatus/GatewaysFloati import { GatewaysStatusInfo } from '../GatewaysStatusInfo/GatewaysStatusInfo'; type Props = { - networkId: number; + networkId?: number; }; export const NetworkGatewaysStatus = ({ networkId }: Props) => { const { @@ -15,7 +15,8 @@ export const NetworkGatewaysStatus = ({ networkId }: Props) => { const { data, isLoading, isError } = useQuery({ queryKey: ['network', networkId, 'gateways'], - queryFn: () => getGatewaysStatus(networkId), + queryFn: () => getGatewaysStatus(networkId as number), + enabled: Boolean(networkId), }); const [totalConnections, connectedCount] = useMemo(() => { @@ -35,7 +36,9 @@ export const NetworkGatewaysStatus = ({ networkId }: Props) => { isError={isError} isLoading={isLoading} > - {data?.map((status) => )} + {data?.map((status) => ( + + ))} ); }; diff --git a/web/src/shared/components/svg/Avatar01Blue.tsx b/web/src/shared/components/svg/Avatar01Blue.tsx index 3af06fc9de..3d91b5afa8 100644 --- a/web/src/shared/components/svg/Avatar01Blue.tsx +++ b/web/src/shared/components/svg/Avatar01Blue.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react'; + const SvgAvatar01Blue = (props: SVGProps) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( diff --git a/web/src/shared/components/svg/IconCancel.tsx b/web/src/shared/components/svg/IconCancel.tsx index aec29831bd..c120692f7d 100644 --- a/web/src/shared/components/svg/IconCancel.tsx +++ b/web/src/shared/components/svg/IconCancel.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react'; + const SvgIconCancel = (props: SVGProps) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => { const maskId = useId(); return ( diff --git a/web/src/shared/components/svg/IconTrash.tsx b/web/src/shared/components/svg/IconTrash.tsx index 8833d02238..d3c0936964 100644 --- a/web/src/shared/components/svg/IconTrash.tsx +++ b/web/src/shared/components/svg/IconTrash.tsx @@ -1,4 +1,5 @@ import type { SVGProps } from 'react'; + const SvgIconTrash = (props: SVGProps) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( ) => ( `${user.first_name} ${user.last_name}`; diff --git a/web/src/shared/helpers/useEffectOnce.ts b/web/src/shared/helpers/useEffectOnce.ts deleted file mode 100644 index 51244c4131..0000000000 --- a/web/src/shared/helpers/useEffectOnce.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useEffect, useRef } from 'react'; - -/** -Under normal circumstances, useEffect should run only once when passed an empty dependency array. -However, in dev mode with react strict mode enabled, everything is rendered twice for debugging purposes. -This also causes useEffect to run twice, which is not always desirable. -This custom hook ensures that the effect runs only once in dev mode as well. -*/ -export default function useEffectOnce(fn: () => void) { - const isMounted = useRef(false); - useEffect(() => { - if (isMounted.current) { - return; - } - - fn(); - isMounted.current = true; - }, [fn]); -} diff --git a/web/src/shared/hooks/api/api.ts b/web/src/shared/hooks/api/api.ts index 552009a34f..86605260ac 100644 --- a/web/src/shared/hooks/api/api.ts +++ b/web/src/shared/hooks/api/api.ts @@ -1,7 +1,7 @@ -import { Axios, AxiosResponse } from 'axios'; +import type { Axios, AxiosResponse } from 'axios'; import { getNetworkStatsFilterValue } from '../../../pages/overview/helpers/stats'; -import { +import type { AddDeviceResponse, AddOpenidClientRequest, AddUserRequest, @@ -20,8 +20,8 @@ import { MFALoginResponse, Network, NetworkToken, - OpenidClient, OpenIdInfo, + OpenidClient, Provisioner, RemoveUserClientRequest, ResetPasswordRequest, @@ -39,60 +39,61 @@ import { WorkerJobStatus, WorkerToken, } from '../../types'; -import { UpdateInfo } from '../store/useUpdatesStore'; +import type { UpdateInfo } from '../store/useUpdatesStore'; const unpackRequest = (res: AxiosResponse): T => res.data; export const buildApi = (client: Axios): Api => { - const addUser = async (data: AddUserRequest) => { - return client.post(`/user`, data).then(unpackRequest); - }; + const getOutdatedInfo = () => client.get(`/outdated`).then(unpackRequest); + + const addUser = (data: AddUserRequest) => + client.post(`/user`, data).then(unpackRequest); const getMe = () => client.get(`/me`).then(unpackRequest); - const getUser: Api['user']['getUser'] = async (username) => + const getUser: Api['user']['getUser'] = (username) => client.get(`/user/${username}`).then(unpackRequest); - const editUser = async ({ username, data }: UserEditRequest) => + const editUser = ({ username, data }: UserEditRequest) => client.put(`/user/${username}`, data).then(unpackRequest); - const deleteUser = async (user: User) => + const deleteUser = (user: User) => client.delete(`/user/${user.username}`).then(unpackRequest); - const fetchDevices = async () => client.get(`/device`).then(unpackRequest); + const fetchDevices = () => client.get(`/device`).then(unpackRequest); - const fetchDevice = async (id: string) => + const fetchDevice = (id: string) => client.get(`/device/${id}`).then(unpackRequest); const getUsers = () => client.get('/user').then(unpackRequest); - const downloadDeviceConfig: Api['device']['downloadDeviceConfig'] = async (data) => + const downloadDeviceConfig: Api['device']['downloadDeviceConfig'] = (data) => client .get(`/network/${data.network_id}/device/${data.device_id}/config`) .then(unpackRequest); - const modifyDevice = async (device: Device) => + const modifyDevice = (device: Device) => client.put(`/device/${device.id}`, device).then(unpackRequest); - const deleteDevice = async (device: Device) => + const deleteDevice = (device: Device) => client.delete(`/device/${device.id}`); - const addDevice: Api['device']['addDevice'] = async ({ username, ...rest }) => + const addDevice: Api['device']['addDevice'] = ({ username, ...rest }) => client.post(`/device/${username}`, rest).then(unpackRequest); - const fetchUserDevices = async (username: string) => + const fetchUserDevices = (username: string) => client.get(`/device/user/${username}`).then(unpackRequest); - const fetchNetworks = async () => client.get(`/network`).then(unpackRequest); + const fetchNetworks = () => client.get(`/network`).then(unpackRequest); - const fetchNetwork = async (id: number) => + const fetchNetwork = (id: number) => client.get(`/network/${id}`).then(unpackRequest); // For now there is only one network - const modifyNetwork: Api['network']['editNetwork'] = async (data) => + const modifyNetwork: Api['network']['editNetwork'] = (data) => client.put(`/network/${data.id}`, data.network).then(unpackRequest); - const deleteNetwork: Api['network']['deleteNetwork'] = async (id) => + const deleteNetwork: Api['network']['deleteNetwork'] = (id) => client.delete(`/network/${id}`); const addNetwork: Api['network']['addNetwork'] = (network) => @@ -172,39 +173,39 @@ export const buildApi = (client: Axios): Api => { const changeWebhookState = ({ id, ...rest }: changeWebhookStateRequest) => client.post(`/webhook/${id}`, rest); - const addWebhook: Api['webhook']['addWebhook'] = async (data) => { + const addWebhook: Api['webhook']['addWebhook'] = (data) => { return client.post('/webhook', data); }; - const editWebhook: Api['webhook']['editWebhook'] = async ({ id, ...rest }) => { + const editWebhook: Api['webhook']['editWebhook'] = ({ id, ...rest }) => { return client.put(`/webhook/${id}`, rest); }; const getOpenidClients = () => client.get('/oauth').then(unpackRequest); - const getOpenidClient = async (client_id: string) => + const getOpenidClient = (client_id: string) => client.get(`/oauth/${client_id}`).then(unpackRequest); - const addOpenidClient = async (data: AddOpenidClientRequest) => { + const addOpenidClient = (data: AddOpenidClientRequest) => { return client.post('/oauth', data); }; - const editOpenidClient = async ({ client_id, ...rest }: EditOpenidClientRequest) => { + const editOpenidClient = ({ client_id, ...rest }: EditOpenidClientRequest) => { return client.put(`/oauth/${client_id}`, rest); }; - const changeOpenidClientState = async ({ + const changeOpenidClientState = ({ clientId, ...rest }: ChangeOpenidClientStateRequest) => { return client.post(`/oauth/${clientId}`, rest); }; - const deleteOpenidClient = async (id: string) => + const deleteOpenidClient = (id: string) => client.delete(`/oauth/${id}`).then(unpackRequest); - const verifyOpenidClient = async (data: VerifyOpenidClientRequest) => + const verifyOpenidClient = (data: VerifyOpenidClientRequest) => client.post('openid/verify', data); - const getUserClients = async (username: string) => + const getUserClients = (username: string) => client.get(`/oauth/apps/${username}`).then(unpackRequest); - const removeUserClient = async (data: RemoveUserClientRequest) => + const removeUserClient = (data: RemoveUserClientRequest) => client .delete(`/user/${data.username}/oauth_app/${data.client_id}`) .then(unpackRequest); @@ -305,10 +306,10 @@ export const buildApi = (client: Axios): Api => { const setDefaultBranding: Api['settings']['setDefaultBranding'] = (id: string) => client.put(`/settings/${id}`).then(unpackRequest); - const downloadSupportData: Api['support']['downloadSupportData'] = async () => + const downloadSupportData: Api['support']['downloadSupportData'] = () => client.get(`/support/configuration`).then(unpackRequest); - const downloadLogs: Api['support']['downloadLogs'] = async () => + const downloadLogs: Api['support']['downloadLogs'] = () => client.get(`/support/logs`).then(unpackRequest); const getGatewaysStatus: Api['network']['getGatewaysStatus'] = (networkId) => @@ -371,6 +372,9 @@ export const buildApi = (client: Axios): Api => { const deleteApiToken: Api['user']['deleteApiToken'] = (data) => client.delete(`/user/${data.username}/api_token/${data.id}`).then(unpackRequest); + const disableUserMfa: Api['user']['disableUserMfa'] = (username) => + client.delete(`/user/${username}/mfa`).then(unpackRequest); + const patchSettings: Api['settings']['patchSettings'] = (data) => client.patch('/settings', data).then(unpackRequest); @@ -423,7 +427,7 @@ export const buildApi = (client: Axios): Api => { const getNewVersion: Api['getNewVersion'] = () => client.get('/updates').then((res) => { - if (res.status === 204) { + if (res.data === null) { return null; } return res.data as UpdateInfo; @@ -542,6 +546,7 @@ export const buildApi = (client: Axios): Api => { ) => client.delete(`/activity_log_stream/${id}`).then(unpackRequest); return { + getOutdatedInfo, getAppInfo, getNewVersion, changePasswordSelf, @@ -620,6 +625,7 @@ export const buildApi = (client: Axios): Api => { addApiToken, deleteApiToken, renameApiToken, + disableUserMfa, }, device: { addDevice: addDevice, diff --git a/web/src/shared/hooks/api/provider.tsx b/web/src/shared/hooks/api/provider.tsx index 9fd2aa5d43..a3c6560fd3 100644 --- a/web/src/shared/hooks/api/provider.tsx +++ b/web/src/shared/hooks/api/provider.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren, useEffect } from 'react'; +import { type PropsWithChildren, useEffect } from 'react'; import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../i18n/i18n-react'; @@ -10,26 +10,26 @@ const ApiContextManager = ({ children }: PropsWithChildren) => { const { LL } = useI18nContext(); + // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater useEffect(() => { if (client && LL && LL.messages) { const defaultResponseInterceptor = client.interceptors.response.use( (res) => { - // API sometimes returns null in optional fields. + // API returns null in optional fields. if (res.data) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment res.data = removeNulls(res.data); } return res; }, (error) => { console.error('Axios Error ', error); + throw error; }, ); return () => { client.interceptors.response.eject(defaultResponseInterceptor); }; } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [LL?.messages, client]); if (!client || !endpoints) return null; diff --git a/web/src/shared/hooks/api/store.ts b/web/src/shared/hooks/api/store.ts index 93a1e275b7..35be229195 100644 --- a/web/src/shared/hooks/api/store.ts +++ b/web/src/shared/hooks/api/store.ts @@ -1,7 +1,7 @@ -import { Axios } from 'axios'; +import type { Axios } from 'axios'; import { createWithEqualityFn } from 'zustand/traditional'; -import { Api } from '../../types'; +import type { Api } from '../../types'; import { buildApi } from './api'; import apiEndpoints from './api-client'; import axiosClient from './axios-client'; diff --git a/web/src/shared/hooks/store/useAppStore.ts b/web/src/shared/hooks/store/useAppStore.ts index ebd1f5c7c1..4fded31c31 100644 --- a/web/src/shared/hooks/store/useAppStore.ts +++ b/web/src/shared/hooks/store/useAppStore.ts @@ -2,8 +2,8 @@ import { pick } from 'lodash-es'; import { createJSONStorage, persist } from 'zustand/middleware'; import { createWithEqualityFn } from 'zustand/traditional'; -import { Locales } from '../../../i18n/i18n-types'; -import { AppInfo, SettingsEnterprise, SettingsEssentials } from '../../types'; +import type { Locales } from '../../../i18n/i18n-types'; +import type { AppInfo, SettingsEnterprise, SettingsEssentials } from '../../types'; const defaultValues: StoreValues = { settings: undefined, diff --git a/web/src/shared/hooks/store/useAuthStore.ts b/web/src/shared/hooks/store/useAuthStore.ts index b0a6cc24e0..8463f035ee 100644 --- a/web/src/shared/hooks/store/useAuthStore.ts +++ b/web/src/shared/hooks/store/useAuthStore.ts @@ -3,7 +3,7 @@ import { Subject } from 'rxjs'; import { createJSONStorage, persist } from 'zustand/middleware'; import { createWithEqualityFn } from 'zustand/traditional'; -import { LoginSubjectData, User } from '../../types'; +import type { LoginSubjectData, User } from '../../types'; export const useAuthStore = createWithEqualityFn()( persist( diff --git a/web/src/shared/hooks/store/useEnterpriseUpgradeStore.tsx b/web/src/shared/hooks/store/useEnterpriseUpgradeStore.tsx index a213e98433..aa4e9598f2 100644 --- a/web/src/shared/hooks/store/useEnterpriseUpgradeStore.tsx +++ b/web/src/shared/hooks/store/useEnterpriseUpgradeStore.tsx @@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware'; import { createWithEqualityFn } from 'zustand/traditional'; import { EnterpriseUpgradeToast } from '../../components/Layout/EnterpriseUpgradeToast/EnterpriseUpgradeToast'; -import { EnterpriseUpgradeToastMeta } from '../../components/Layout/EnterpriseUpgradeToast/types'; +import type { EnterpriseUpgradeToastMeta } from '../../components/Layout/EnterpriseUpgradeToast/types'; import { versionUpdateToastMetaSchema } from '../../components/Layout/VersionUpdateToast/types'; import { ToastType } from '../../defguard-ui/components/Layout/ToastManager/Toast/types'; import { useToastsStore } from '../../defguard-ui/hooks/toasts/useToastStore'; diff --git a/web/src/shared/hooks/store/useModalStore.ts b/web/src/shared/hooks/store/useModalStore.ts index 98c7ad77b7..ff8491e676 100644 --- a/web/src/shared/hooks/store/useModalStore.ts +++ b/web/src/shared/hooks/store/useModalStore.ts @@ -1,6 +1,6 @@ import { createWithEqualityFn } from 'zustand/traditional'; -import { UseModalStore } from '../../types'; +import type { UseModalStore } from '../../types'; /** * This approach is deprecated, please use separate stores for each modal to keep things clean diff --git a/web/src/shared/hooks/store/useOpenidClientStore.tsx b/web/src/shared/hooks/store/useOpenidClientStore.tsx index 9ed64776c1..aef345c438 100644 --- a/web/src/shared/hooks/store/useOpenidClientStore.tsx +++ b/web/src/shared/hooks/store/useOpenidClientStore.tsx @@ -1,6 +1,6 @@ import { createWithEqualityFn } from 'zustand/traditional'; -import { OpenidClientStore } from '../../types'; +import type { OpenidClientStore } from '../../types'; export const useOpenidClientStore = createWithEqualityFn( (set) => ({ diff --git a/web/src/shared/hooks/store/useUpdatesStore.tsx b/web/src/shared/hooks/store/useUpdatesStore.tsx index 4a494aa179..5aa30501af 100644 --- a/web/src/shared/hooks/store/useUpdatesStore.tsx +++ b/web/src/shared/hooks/store/useUpdatesStore.tsx @@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware'; import { createWithEqualityFn } from 'zustand/traditional'; import { - VersionUpdateToastMeta, + type VersionUpdateToastMeta, versionUpdateToastMetaSchema, } from '../../components/Layout/VersionUpdateToast/types'; import { VersionUpdateToast } from '../../components/Layout/VersionUpdateToast/VersionUpdateToast'; diff --git a/web/src/shared/hooks/store/useUserProfileStore.ts b/web/src/shared/hooks/store/useUserProfileStore.ts index 8610f441a6..2c0a79be11 100644 --- a/web/src/shared/hooks/store/useUserProfileStore.ts +++ b/web/src/shared/hooks/store/useUserProfileStore.ts @@ -1,7 +1,7 @@ import { Subject } from 'rxjs'; import { createWithEqualityFn } from 'zustand/traditional'; -import { UserProfile } from '../../types'; +import type { UserProfile } from '../../types'; const defaultValues: StoreValues = { editMode: false, diff --git a/web/src/shared/hooks/useClipboard.tsx b/web/src/shared/hooks/useClipboard.tsx index 95340d2242..809c80caf6 100644 --- a/web/src/shared/hooks/useClipboard.tsx +++ b/web/src/shared/hooks/useClipboard.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useI18nContext } from '../../i18n/i18n-react'; -import { useToaster } from './useToaster'; +import { useToaster } from '../defguard-ui/hooks/toasts/useToaster'; export const useClipboard = () => { const { LL } = useI18nContext(); diff --git a/web/src/shared/hooks/useToaster.tsx b/web/src/shared/hooks/useToaster.tsx index 0b15f90bde..8e42f82471 100644 --- a/web/src/shared/hooks/useToaster.tsx +++ b/web/src/shared/hooks/useToaster.tsx @@ -1,36 +1,3 @@ -import { ToastType } from '../defguard-ui/components/Layout/ToastManager/Toast/types'; -import { useToastsStore } from '../defguard-ui/hooks/toasts/useToastStore'; +import { useToaster } from '../defguard-ui/hooks/toasts/useToaster'; -export const useToaster = () => { - const addToast = useToastsStore((store) => store.addToast); - - const success = (message: string, subMessage?: string) => - addToast({ - type: ToastType.SUCCESS, - message, - subMessage, - }); - - const info = (message: string, subMessage?: string) => - addToast({ - type: ToastType.INFO, - message, - subMessage, - }); - - const warning = (message: string, subMessage?: string) => - addToast({ - type: ToastType.WARNING, - message, - subMessage, - }); - - const error = (message: string, subMessage?: string) => - addToast({ - type: ToastType.ERROR, - message, - subMessage, - }); - - return { success, info, warning, error }; -}; +export { useToaster }; diff --git a/web/src/shared/images/png/manual-config-1.png b/web/src/shared/images/png/manual-config-1.png index 634bf205da..90f6e741bf 100644 Binary files a/web/src/shared/images/png/manual-config-1.png and b/web/src/shared/images/png/manual-config-1.png differ diff --git a/web/src/shared/links.ts b/web/src/shared/links.ts index 212938b060..3673249e31 100644 --- a/web/src/shared/links.ts +++ b/web/src/shared/links.ts @@ -6,11 +6,11 @@ export const externalLink = { base: 'https://docs.defguard.net/', setup: { gateway: - 'https://docs.defguard.net/admin-and-features/setting-up-your-instance/gateway', + 'https://docs.defguard.net/deployment-strategies/gateway', }, wireguard: { addDevices: - 'https://docs.defguard.net/help/configuring-vpn/adding-wireguard-devices', + 'https://docs.defguard.net/using-defguard-for-end-users/adding-wireguard-devices', }, }, defguardSite: 'https://defguard.net', @@ -18,5 +18,12 @@ export const externalLink = { wireguard: { download: 'https://www.wireguard.com/install/', }, - defguardCliDocs: 'https://docs.defguard.net/help/cli-client', + defguardCliDocs: 'https://docs.defguard.net/using-defguard-for-end-users/cli-client', + clientApp: { + download: { + android: 'https://play.google.com/store/apps/details?id=net.defguard.mobile', + ios: 'https://testflight.apple.com/join/Jvdhkt7h', + desktop: 'https://defguard.net/download/', + }, + }, }; diff --git a/web/src/shared/patterns.ts b/web/src/shared/patterns.ts index c03040997b..cfb5db0bdb 100644 --- a/web/src/shared/patterns.ts +++ b/web/src/shared/patterns.ts @@ -23,7 +23,7 @@ export const patternValidWireguardKey = export const patternBaseUrl = /:\/\/(.[^/]+)/; -export const patternNumbersOnly = new RegExp('^[0-9]+$'); +export const patternNumbersOnly = /^[0-9]+$/; // https://gist.github.com/dperini/729294 export const patternValidUrl = new RegExp( @@ -65,14 +65,13 @@ export const patternValidUrl = new RegExp( ); export const patternValidDomain = - /^(?:(?:(?:[a-zA-z\-]+)\:\/{1,3})?(?:[a-zA-Z0-9])(?:[a-zA-Z0-9\-\.]){1,61}(?:\.[a-zA-Z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))(?:\:[0-9]{1,5})?$/; + /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9\-.]){1,61}(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))(?::[0-9]{1,5})?$/; export const patternSafeUsernameCharacters = /^[a-zA-Z0-9]+[a-zA-Z0-9.\-_]*$/; export const patternLoginCharacters = /^[a-zA-Z0-9]+[a-zA-Z0-9.\-_@]*$/; -export const patternSafePasswordCharacters = - /^[a-zA-Z0-9.!@#$%^&*()_+\-=\[\]{}|,<>\/?~]+$/; +export const patternSafePasswordCharacters = /^[a-zA-Z0-9.!@#$%^&*()_+\-=[\]{}|,<>/?~]+$/; export const patternStrictIpV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; diff --git a/web/src/shared/scss/base/_base.scss b/web/src/shared/scss/base/_base.scss index fd7f04b351..e403a28fa0 100644 --- a/web/src/shared/scss/base/_base.scss +++ b/web/src/shared/scss/base/_base.scss @@ -99,12 +99,29 @@ html { body { font-size: 1.6rem; color: v.$gray-light; - font-family: Poppins, 'Roboto Condensed', Roboto, 'Open Sans', Helvetica, Arial; + font-family: + Poppins, + Roboto, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Oxygen, + Ubuntu, + Cantarell, + 'Open Sans', + 'Helvetica Neue', + sans-serif; background: v.$bg-light; overflow-x: hidden; - height: inherit; - max-height: inherit; position: relative; + max-height: 100dvh; +} + +#app, +#root { + position: relative; + min-height: 100dvh; } h1, diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index accee9a10f..f1aea2ddb0 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -1,14 +1,31 @@ -import { +import type { CredentialCreationOptionsJSON, CredentialRequestOptionsJSON, PublicKeyCredentialWithAssertionJSON, PublicKeyCredentialWithAttestationJSON, } from '@github/webauthn-json'; -import { AxiosError, AxiosPromise } from 'axios'; +import type { AxiosError, AxiosPromise } from 'axios'; -import { AclAlias, AclStatus } from '../pages/acl/types'; -import { ActivityLogEventType, ActivityLogModule } from '../pages/activity-log/types'; -import { UpdateInfo } from './hooks/store/useUpdatesStore'; +import type { AclAlias, AclStatus } from '../pages/acl/types'; +import type { + ActivityLogEventType, + ActivityLogModule, +} from '../pages/activity-log/types'; +import type { UpdateInfo } from './hooks/store/useUpdatesStore'; + +export type OutdatedProxy = { + version: string; +}; + +export type OutdatedGateway = { + version?: string; + hostname?: string; +}; + +export type OutdatedComponents = { + proxy?: OutdatedProxy; + gateways: OutdatedGateway[]; +}; export type ApiError = AxiosError; @@ -58,12 +75,13 @@ export type UserProfile = { user: User; devices: Device[]; security_keys: SecurityKey[]; + biometric_enabled_devices: number[]; }; export interface OAuth2AuthorizedApps { - oauth2client_id: string; + oauth2client_id: number; oauth2client_name: string; - user_id: string; + user_id: number; } export interface SecurityKey { @@ -115,6 +133,12 @@ export type GatewayStatus = { uid: string; }; +export enum LocationMfaMode { + DISABLED = 'disabled', + INTERNAL = 'internal', + EXTERNAL = 'external', +} + export interface Network { id: number; name: string; @@ -127,11 +151,11 @@ export interface Network { allowed_ips?: string[]; allowed_groups?: string[]; dns?: string; - mfa_enabled: boolean; keepalive_interval: number; peer_disconnect_threshold: number; acl_enabled: boolean; acl_default_allow: boolean; + location_mfa_mode: LocationMfaMode; } export type ModifyNetworkRequest = { @@ -312,7 +336,7 @@ export interface RecoveryLoginRequest { code: string; } -export type MFARecoveryCodesResponse = Promise; +export type MFARecoveryCodesResponse = Promise; export interface VersionResponse { version: string; @@ -350,6 +374,7 @@ export interface AppInfo { smtp_enabled: boolean; license_info: LicenseInfo; ldap_info: LdapInfo; + external_openid_enabled: boolean; } export type GetDeviceConfigRequest = { @@ -504,11 +529,12 @@ export type ActivityLogEvent = { timestamp: string; user_id: number; username: string; + location?: string; ip: string; event: ActivityLogEventType; module: ActivityLogModule; device: string; - metadata?: unknown; + description?: string; }; export type PaginationParams = { @@ -536,6 +562,7 @@ export type ActivityLogFilters = { // Naive UTC datetime in string until?: string; username?: string[]; + location?: string[]; event?: ActivityLogEventType[]; module?: ActivityLogModule[]; search?: string; @@ -544,6 +571,7 @@ export type ActivityLogFilters = { export type ActivityLogSortKey = | 'timestamp' | 'username' + | 'location' | 'ip' | 'event' | 'module' @@ -597,6 +625,7 @@ export type ActivityLogStreamConfig = export type ActivityLogStreamCreateRequest = Omit; export type Api = { + getOutdatedInfo: () => Promise; getAppInfo: () => Promise; getNewVersion: () => Promise; changePasswordSelf: (data: ChangePasswordSelfRequest) => Promise; @@ -685,6 +714,7 @@ export type Api = { username: string; name: string; }) => EmptyApiResponse; + disableUserMfa: (username: string) => EmptyApiResponse; }; standaloneDevice: { createManualDevice: ( @@ -701,7 +731,7 @@ export type Api = { ) => Promise; validateLocationIp: ( data: ValidateLocationIpsRequest, - ) => Promise; + ) => Promise; getDevicesList: () => Promise; getDeviceConfig: (deviceId: number | string) => Promise; generateAuthToken: (deviceId: number | string) => Promise; @@ -1156,6 +1186,18 @@ export interface OpenIdProvider { directory_sync_group_match?: string; } +export enum OpenIdSyncBehavior { + KEEP = 'keep', + DISABLE = 'disable', + DELETE = 'delete', +} + +export enum OpenIdSyncTarget { + ALL = 'all', + USERS = 'users', + GROUPS = 'groups', +} + export interface EditOpenidClientRequest { id: string; name: string; @@ -1209,7 +1251,6 @@ export enum OverviewLayoutType { export interface OverviewStore { viewMode: OverviewLayoutType; defaultViewMode: OverviewLayoutType; - statsFilter: number; networks?: Network[]; selectedNetworkId?: number; setState: (override: Partial) => void; @@ -1273,7 +1314,7 @@ export interface WebAuthnRegistrationRequest { export interface RemoveUserClientRequest { username: string; - client_id: string; + client_id: number; } export interface TestMail { @@ -1310,7 +1351,7 @@ export type ValidateLocationIpsRequest = { location: number | string; }; -export type ValidateLocationIpsResponse = { +export type ValidateLocationIpsResult = { available: boolean; valid: boolean; }; @@ -1355,10 +1396,10 @@ export type DeviceConfigurationResponse = { config: string; endpoint: string; keepalive_interval: number; - mfa_enabled: boolean; network_id: number; network_name: string; pubkey: string; + location_mfa_mode: LocationMfaMode; }; export type CreateStandaloneDeviceResponse = { diff --git a/web/src/shared/utils/convertFromStringToBuffer.ts b/web/src/shared/utils/convertFromStringToBuffer.ts deleted file mode 100644 index b250a92de5..0000000000 --- a/web/src/shared/utils/convertFromStringToBuffer.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const convertFromStringToBuffer = (value: string): ArrayBuffer => { - // Encode with UTF-8 - const encoder = new TextEncoder(); - return encoder.encode(value); -}; diff --git a/web/src/shared/utils/displayDate.ts b/web/src/shared/utils/displayDate.ts new file mode 100644 index 0000000000..2d5a93ac72 --- /dev/null +++ b/web/src/shared/utils/displayDate.ts @@ -0,0 +1,3 @@ +import dayjs, { type Dayjs } from 'dayjs'; + +export const dateToLocal = (value: string): Dayjs => dayjs.utc(value).local(); diff --git a/web/src/shared/utils/form/selectifyNetwork.ts b/web/src/shared/utils/form/selectifyNetwork.ts index 8ed7f4535e..e21a728edd 100644 --- a/web/src/shared/utils/form/selectifyNetwork.ts +++ b/web/src/shared/utils/form/selectifyNetwork.ts @@ -1,5 +1,5 @@ -import { SelectOption } from '../../defguard-ui/components/Layout/Select/types'; -import { Network } from '../../types'; +import type { SelectOption } from '../../defguard-ui/components/Layout/Select/types'; +import type { Network } from '../../types'; export const selectifyNetworks = (data: Network[]): SelectOption[] => data.map((network) => ({ diff --git a/web/src/shared/utils/invalidateMultipleQueries.ts b/web/src/shared/utils/invalidateMultipleQueries.ts index c4ce2adba0..cb49123bb5 100644 --- a/web/src/shared/utils/invalidateMultipleQueries.ts +++ b/web/src/shared/utils/invalidateMultipleQueries.ts @@ -1,4 +1,4 @@ -import { QueryClient, QueryKey } from '@tanstack/query-core'; +import type { QueryClient, QueryKey } from '@tanstack/query-core'; export const invalidateMultipleQueries = ( client: QueryClient, diff --git a/web/src/shared/utils/localeToDatepicker.ts b/web/src/shared/utils/localeToDatepicker.ts index c2008e07a7..4cf878c77e 100644 --- a/web/src/shared/utils/localeToDatepicker.ts +++ b/web/src/shared/utils/localeToDatepicker.ts @@ -1,4 +1,4 @@ -import { Locales } from '../../i18n/i18n-types'; +import type { Locales } from '../../i18n/i18n-types'; export const localeToDatePicker = (val: Locales): string => { switch (val) { diff --git a/web/src/shared/utils/removeNulls.ts b/web/src/shared/utils/removeNulls.ts index fb99b6088f..a6af5697f1 100644 --- a/web/src/shared/utils/removeNulls.ts +++ b/web/src/shared/utils/removeNulls.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ +// biome-ignore lint/suspicious/noExplicitAny: should be like this export const removeNulls = (obj: any) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(JSON.stringify(obj), (_, value) => { diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts index a37cad509a..4bc73aa9df 100644 --- a/web/src/shared/validators.ts +++ b/web/src/shared/validators.ts @@ -86,8 +86,8 @@ export const validateIPv6 = (ip: string, allowMask = false): boolean => { }; export const validatePort = (val: string) => { - const parsed = parseInt(val); - if (!isNaN(parsed)) { + const parsed = parseInt(val, 10); + if (!Number.isNaN(parsed)) { return parsed <= 65535; } }; diff --git a/web/src/shared/validators/password.ts b/web/src/shared/validators/password.ts index f9830c9189..1be1c3d2b0 100644 --- a/web/src/shared/validators/password.ts +++ b/web/src/shared/validators/password.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { TranslationFunctions } from '../../i18n/i18n-types'; +import type { TranslationFunctions } from '../../i18n/i18n-types'; import { patternAtLeastOneDigit, patternAtLeastOneLowerCaseChar, diff --git a/web/src/shared/variants.ts b/web/src/shared/variants.ts index dbbb357515..2c110ee1a2 100644 --- a/web/src/shared/variants.ts +++ b/web/src/shared/variants.ts @@ -1,4 +1,4 @@ -import { Variants } from 'framer-motion'; +import type { Variants } from 'motion/react'; export const tableBodyVariants: Variants = { hidden: { diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index 4b847ecdf3..cbcdbd8292 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -24,8 +24,8 @@ "noFallthroughCasesInSwitch": true, "plugins": [ { - "name": "typescript-eslint-language-service", - }, + "name": "typescript-eslint-language-service" + } ], "types": [ "vite/client" @@ -38,5 +38,5 @@ "node_modules", "dist", "build" - ], -} \ No newline at end of file + ] +} diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo new file mode 100644 index 0000000000..23ff5a3be9 --- /dev/null +++ b/web/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/gif.d.ts","./src/main.tsx","./src/markdown.d.ts","./src/vite.d.ts","./src/components/apploader.tsx","./src/components/i18nprovider.tsx","./src/components/app/app.tsx","./src/components/navigation/navigation.tsx","./src/components/navigation/config.ts","./src/components/navigation/types.ts","./src/components/navigation/components/devicespagenavigationicon.tsx","./src/components/navigation/components/applicationversion/applicationversion.tsx","./src/components/navigation/components/navigationbar/navigationbar.tsx","./src/components/navigation/components/navigationdesktop/navigationdesktop.tsx","./src/components/navigation/components/navigationdesktop/navigationcollapse/navigationcollapse.tsx","./src/components/navigation/components/navigationlink/navigationlink.tsx","./src/components/navigation/components/navigationmobile/navigationmobile.tsx","./src/components/navigation/components/navigationmobile/mobilenavmodal/mobilenavmodal.tsx","./src/components/navigation/components/icons/navigationactivitylogpageicon.tsx","./src/components/navigation/hooks/usenavigationstore.ts","./src/i18n/formatters.ts","./src/i18n/i18n-react.tsx","./src/i18n/i18n-types.ts","./src/i18n/i18n-util.async.ts","./src/i18n/i18n-util.sync.ts","./src/i18n/i18n-util.ts","./src/i18n/en/index.ts","./src/i18n/ko/index.ts","./src/i18n/pl/index.ts","./src/pages/acl/aclcreatedataprovider.tsx","./src/pages/acl/aclroutes.tsx","./src/pages/acl/acl-context.tsx","./src/pages/acl/types.ts","./src/pages/acl/utils.ts","./src/pages/acl/validators.ts","./src/pages/acl/aclcreatepage/aclcreatepage.tsx","./src/pages/acl/aclcreatepage/components/dialogselect/dialogselect.tsx","./src/pages/acl/aclcreatepage/components/dialogselect/dialogselectbuttonicon.tsx","./src/pages/acl/aclcreatepage/components/dialogselect/formdialogselect.tsx","./src/pages/acl/aclcreatepage/components/dialogselect/types.ts","./src/pages/acl/aclcreatepage/components/dialogselect/dialogselectmodal/dialogselectmodal.tsx","./src/pages/acl/aclindexpage/aclindexpage.tsx","./src/pages/acl/aclindexpage/components/deploychangesicon.tsx","./src/pages/acl/aclindexpage/components/aclindexaliases/aclindexaliases.tsx","./src/pages/acl/aclindexpage/components/aclindexaliases/types.ts","./src/pages/acl/aclindexpage/components/aclindexaliases/components/aliaseditbutton.tsx","./src/pages/acl/aclindexpage/components/aclindexaliases/components/aliaseslist.tsx","./src/pages/acl/aclindexpage/components/aclindexaliases/components/aclaliasstatus/aclaliasstatus.tsx","./src/pages/acl/aclindexpage/components/aclindexaliases/modals/aclaliasapplyconfirmmodal/aclaliasapplyconfirmmodal.tsx","./src/pages/acl/aclindexpage/components/aclindexaliases/modals/aclaliasdeleteblockmodal/aclaliasdeleteblockmodal.tsx","./src/pages/acl/aclindexpage/components/aclindexaliases/modals/aclaliasdeleteblockmodal/store.tsx","./src/pages/acl/aclindexpage/components/aclindexaliases/modals/alcaliascemodal/alcaliascemodal.tsx","./src/pages/acl/aclindexpage/components/aclindexaliases/modals/alcaliascemodal/store.tsx","./src/pages/acl/aclindexpage/components/aclindexrules/aclindexrules.tsx","./src/pages/acl/aclindexpage/components/aclindexrules/components/aclrulestatus/aclrulestatus.tsx","./src/pages/acl/aclindexpage/components/aclindexrules/components/aclrulesapplyconfirmmodal/aclrulesapplyconfirmmodal.tsx","./src/pages/acl/aclindexpage/components/acllistskeleton/acllistskeleton.tsx","./src/pages/acl/aclindexpage/components/shared/aclaliaskindicon.tsx","./src/pages/acl/aclindexpage/components/shared/dividerheader.tsx","./src/pages/acl/aclindexpage/components/shared/networkaccesstypeicon.tsx","./src/pages/acl/aclindexpage/components/shared/types.ts","./src/pages/acl/aclindexpage/components/shared/aclmessageboxes/aclmessageboxes.tsx","./src/pages/activity-log/activitylogpage.tsx","./src/pages/activity-log/types.ts","./src/pages/activity-log/components/activitylist.tsx","./src/pages/activity-log/components/activitytimerangemodal.tsx","./src/pages/adddevice/adddevicepage.tsx","./src/pages/adddevice/types.ts","./src/pages/adddevice/hooks/useadddevicepagestore.tsx","./src/pages/adddevice/steps/adddeviceclientconfigurationstep/adddeviceclientconfigurationstep.tsx","./src/pages/adddevice/steps/adddeviceconfigstep/adddeviceconfigstep.tsx","./src/pages/adddevice/steps/adddevicesetupmethodstep/adddevicesetupmethodstep.tsx","./src/pages/adddevice/steps/adddevicesetupmethodstep/types.ts","./src/pages/adddevice/steps/adddevicesetupmethodstep/components/devicesetupmethodcard/devicesetupmethodcard.tsx","./src/pages/adddevice/steps/adddevicesetupstep/adddevicesetupstep.tsx","./src/pages/adddevice/utils/enrollmenttotoken.ts","./src/pages/allow/openidallowpage.tsx","./src/pages/auth/authpage.tsx","./src/pages/auth/callback/callback.tsx","./src/pages/auth/login/login.tsx","./src/pages/auth/login/components/oidcbuttons.tsx","./src/pages/auth/mfaroute/mfaroute.tsx","./src/pages/auth/mfaroute/mfaemail/mfaemail.tsx","./src/pages/auth/mfaroute/mfanav/mfanav.tsx","./src/pages/auth/mfaroute/mfarecovery/mfarecovery.tsx","./src/pages/auth/mfaroute/mfatotpauth/mfatotpauth.tsx","./src/pages/auth/mfaroute/mfawebauthn/mfawebauthn.tsx","./src/pages/auth/shared/hooks/usemfastore.tsx","./src/pages/devices/devicespage.tsx","./src/pages/devices/types.ts","./src/pages/devices/components/adddeviceicon.tsx","./src/pages/devices/components/deviceslist/deviceslist.tsx","./src/pages/devices/components/deviceslist/modals/confirmdevicedeletemodal.tsx","./src/pages/devices/hooks/usedeletestandalonedevicemodal.tsx","./src/pages/devices/hooks/usedevicespage.tsx","./src/pages/devices/hooks/useeditstandalonedevicemodal.tsx","./src/pages/devices/modals/addstandalonedevicemodal/addstandalonedevicemodal.tsx","./src/pages/devices/modals/addstandalonedevicemodal/store.tsx","./src/pages/devices/modals/addstandalonedevicemodal/types.ts","./src/pages/devices/modals/addstandalonedevicemodal/steps/finishclistep/finishclistep.tsx","./src/pages/devices/modals/addstandalonedevicemodal/steps/finishmanualstep/finishmanualstep.tsx","./src/pages/devices/modals/addstandalonedevicemodal/steps/methodstep/methodstep.tsx","./src/pages/devices/modals/addstandalonedevicemodal/steps/setupclistep/setupclistep.tsx","./src/pages/devices/modals/addstandalonedevicemodal/steps/setupmanualstep/setupmanualstep.tsx","./src/pages/devices/modals/editstandalonedevicemodal/editstandalonemodal.tsx","./src/pages/devices/modals/standalonedeviceconfigmodal/standalonedeviceconfigmodal.tsx","./src/pages/devices/modals/standalonedeviceconfigmodal/store.tsx","./src/pages/devices/modals/standalonedeviceenrollmentmodal/standalonedeviceenrollmentmodal.tsx","./src/pages/devices/modals/standalonedeviceenrollmentmodal/store.tsx","./src/pages/devices/modals/components/types.ts","./src/pages/devices/modals/components/standalonedevicemodalenrollmentcontent/standalonedevicemodalenrollmentcontent.tsx","./src/pages/devices/modals/components/standalonedevicemodalform/standalonedevicemodalform.tsx","./src/pages/enrollment/enrollmentpage.tsx","./src/pages/enrollment/components/enrollmentemail/enrollmentemail.tsx","./src/pages/enrollment/components/enrollmentvpn/enrollmentvpn.tsx","./src/pages/enrollment/components/enrollmentwelcomemessage/enrollmentwelcomemessage.tsx","./src/pages/enrollment/hooks/useenrollmentstore.tsx","./src/pages/groups/groupspage.tsx","./src/pages/groups/components/groupslist/groupslist.tsx","./src/pages/groups/components/groupsmanagement/groupsmanagement.tsx","./src/pages/groups/components/modals/addgroupmodal/addgroupmodal.tsx","./src/pages/groups/components/modals/addgroupmodal/useaddgroupmodal.tsx","./src/pages/groups/components/modals/addgroupmodal/components/groupformselectall/groupformselectall.tsx","./src/pages/groups/components/modals/addgroupmodal/components/userselect/userselect.tsx","./src/pages/loader/loaderpage.tsx","./src/pages/network/networkpage.tsx","./src/pages/network/networkcontrols/networkcontrols.tsx","./src/pages/network/networkeditform/networkeditform.tsx","./src/pages/network/networkgateway/networkgateway.tsx","./src/pages/network/networktabs/networktabs.tsx","./src/pages/network/hooks/usenetworkpagestore.ts","./src/pages/openid/openidclientslistpage/openidclientslistpage.tsx","./src/pages/openid/modals/openidclientmodal/openidclientmodal.tsx","./src/pages/openid/modals/openidclientmodal/openidclientmodalform.tsx","./src/pages/openid/modals/openidclientmodal/types.ts","./src/pages/openid/modals/openidclientmodal/components/openidclientmodalformscopes.tsx","./src/pages/overview/overviewpage.tsx","./src/pages/overview/overviewconnectedusers/overviewconnectedusers.tsx","./src/pages/overview/overviewconnectedusers/userconnectioncard/userconnectioncard.tsx","./src/pages/overview/overviewconnectedusers/userconnectioncard/formatconnectiontime.ts","./src/pages/overview/overviewconnectedusers/userconnectionlistitem/userconnectionlistitem.tsx","./src/pages/overview/overviewconnectedusers/shared/components/networkusagechart/networkusagechart.tsx","./src/pages/overview/overviewexpandable/overviewexpandable.tsx","./src/pages/overview/overviewheader/overviewheader.tsx","./src/pages/overview/overviewheader/overviewnetworkselect/overviewnetworkselect.tsx","./src/pages/overview/overviewstats/overviewstats.tsx","./src/pages/overview/overviewstats/utils.ts","./src/pages/overview/overviewstatsfilterselect/overviewstatsfilterselect.tsx","./src/pages/overview/overviewviewselect/overviewviewselect.tsx","./src/pages/overview/helpers/stats.ts","./src/pages/overview/hooks/store/useoverviewstore.ts","./src/pages/overview-index/overviewindexpage.tsx","./src/pages/overview-index/components/editlocationssettingsbutton/editlocationssettingsbutton.tsx","./src/pages/overview-index/components/overviewnetworkselection/overviewnetworkselection.tsx","./src/pages/overview-index/components/overviewtimeselection/overviewtimeselection.tsx","./src/pages/overview-index/components/hooks/useoverviewtimeselection.ts","./src/pages/provisioners/provisionerspage.tsx","./src/pages/provisioners/components/provisionerslist/provisionerslist.tsx","./src/pages/provisioners/components/provisioningstationsetupcard/provisioningstationsetupcard.tsx","./src/pages/provisioners/components/modals/deleteprovisionermodal.tsx","./src/pages/provisioners/components/modals/usedeleteprovisionermodal.tsx","./src/pages/redirect/redirectpage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/settings/components/activitylogstreamsettings/activitylogstreamsettings.tsx","./src/pages/settings/components/activitylogstreamsettings/modals/createactivitylogstreammodal/createactivitylogstreammodal.tsx","./src/pages/settings/components/activitylogstreamsettings/modals/createactivitylogstreammodal/store.tsx","./src/pages/settings/components/activitylogstreamsettings/modals/logstashhttpstreamcemodal/logstashhttpstreamcemodal.tsx","./src/pages/settings/components/activitylogstreamsettings/modals/logstashhttpstreamcemodal/store.tsx","./src/pages/settings/components/activitylogstreamsettings/modals/vectorhttpstreamcemodal/vectorhttpstreamcemodal.tsx","./src/pages/settings/components/activitylogstreamsettings/modals/vectorhttpstreamcemodal/store.tsx","./src/pages/settings/components/activitylogstreamsettings/utils/activitylogstreamtolabel.ts","./src/pages/settings/components/enterprisesettings/enterprisesettings.tsx","./src/pages/settings/components/enterprisesettings/components/enterpriseform.tsx","./src/pages/settings/components/globalsettings/globalsettings.tsx","./src/pages/settings/components/globalsettings/components/globalsettingsform/globalsettingsform.tsx","./src/pages/settings/components/globalsettings/components/licensesettings/licensesettings.tsx","./src/pages/settings/components/ldapsettings/ldapsettings.tsx","./src/pages/settings/components/ldapsettings/components/ldapconnectiontest.tsx","./src/pages/settings/components/ldapsettings/components/ldapsettingsform.tsx","./src/pages/settings/components/ldapsettings/components/ldapsettingsleft.tsx","./src/pages/settings/components/ldapsettings/components/ldapsettingsright.tsx","./src/pages/settings/components/notificationsettings/notificationsettings.tsx","./src/pages/settings/components/notificationsettings/components/gatewaynotificationsform.tsx","./src/pages/settings/components/notificationsettings/components/notificationsettingsform.tsx","./src/pages/settings/components/openidsettings/openidsettings.tsx","./src/pages/settings/components/openidsettings/components/directorysyncsettings.tsx","./src/pages/settings/components/openidsettings/components/openidgeneralsettings.tsx","./src/pages/settings/components/openidsettings/components/openidprovidersettings.tsx","./src/pages/settings/components/openidsettings/components/openidsettingsform.tsx","./src/pages/settings/components/openidsettings/components/supportedproviders.ts","./src/pages/settings/components/smtpsettings/smtpsettings.tsx","./src/pages/settings/components/smtpsettings/components/smtpsettingsform/smtpsettingsform.tsx","./src/pages/settings/components/smtpsettings/components/smtptest/smtptest.tsx","./src/pages/settings/components/smtpsettings/components/smtptest/smtptestmodal.tsx","./src/pages/settings/components/smtpsettings/components/smtptest/usesmtptestmodal.ts","./src/pages/settings/hooks/usesettingspage.tsx","./src/pages/support/supportpage.tsx","./src/pages/support/components/builtbycard/builtbycard.tsx","./src/pages/support/components/debugdatacard/debugdatacard.tsx","./src/pages/support/components/debugdatacard/components/sendsupportdatamodal.tsx","./src/pages/support/components/supportcard/supportcard.tsx","./src/pages/users/userspage.tsx","./src/pages/users/userssharedmodals.tsx","./src/pages/users/userprofile/userprofile.tsx","./src/pages/users/userprofile/profiledetails/profiledetails.tsx","./src/pages/users/userprofile/profiledetails/profiledetailsform/profiledetailsform.tsx","./src/pages/users/userprofile/profiledetails/profiledetailsform/profiledetailsformappsfield.tsx","./src/pages/users/userprofile/userapitokens/userapitokens.tsx","./src/pages/users/userprofile/userapitokens/apitokenlist/apitokenlist.tsx","./src/pages/users/userprofile/userapitokens/apitokenlist/apitokenitem/apitokenitem.tsx","./src/pages/users/userprofile/userapitokens/deleteapitokenmodal/deleteapitokenmodal.tsx","./src/pages/users/userprofile/userapitokens/deleteapitokenmodal/usedeleteapitokenmodal.ts","./src/pages/users/userprofile/userauthinfo/userauthinfo.tsx","./src/pages/users/userprofile/userauthinfo/userauthinfomfa.tsx","./src/pages/users/userprofile/userauthinfo/userauthinfopassword.tsx","./src/pages/users/userprofile/userauthinfo/userauthinforecovery.tsx","./src/pages/users/userprofile/userauthinfo/modals/changeselfpasswordmodal/changeselfpasswordmodal.tsx","./src/pages/users/userprofile/userauthinfo/modals/changeselfpasswordmodal/components/changeselfpasswordform.tsx","./src/pages/users/userprofile/userauthinfo/modals/changeselfpasswordmodal/hooks/usechangeselfpasswordmodal.ts","./src/pages/users/userprofile/userauthinfo/modals/managewebauthnmodal/managewebauthnmodal.tsx","./src/pages/users/userprofile/userauthinfo/modals/managewebauthnmodal/components/registerwebauthnform.tsx","./src/pages/users/userprofile/userauthinfo/modals/managewebauthnmodal/components/webauthnkeyrow.tsx","./src/pages/users/userprofile/userauthinfo/modals/recoverycodesmodal/recoverycodesmodal.tsx","./src/pages/users/userprofile/userauthinfo/modals/registeremailmfamodal/registeremailmfamodal.tsx","./src/pages/users/userprofile/userauthinfo/modals/registeremailmfamodal/components/registermfaemailform/registermfaemailform.tsx","./src/pages/users/userprofile/userauthinfo/modals/registeremailmfamodal/hooks/useemailmfamodal.tsx","./src/pages/users/userprofile/userauthinfo/modals/registertotpmodal/registertotpmodal.tsx","./src/pages/users/userprofile/userauthenticationkeys/userauthenticationkeys.tsx","./src/pages/users/userprofile/userauthenticationkeys/authenticationkeylist/authenticationkeylist.tsx","./src/pages/users/userprofile/userauthenticationkeys/authenticationkeylist/authenticationkeyitem/authenticationkeyitem.tsx","./src/pages/users/userprofile/userauthenticationkeys/authenticationkeylist/authenticationkeyitemyubikey/authenticationkeyitemyubikey.tsx","./src/pages/users/userprofile/userauthenticationkeys/deleteauthenticationkeymodal/deleteauthenticationkeymodal.tsx","./src/pages/users/userprofile/userauthenticationkeys/deleteauthenticationkeymodal/usedeleteauthenticationkeymodal.ts","./src/pages/users/userprofile/userdevices/userdevices.tsx","./src/pages/users/userprofile/userdevices/devicecard/devicecard.tsx","./src/pages/users/userprofile/userdevices/hooks/usedeletedevicemodal.ts","./src/pages/users/userprofile/userdevices/hooks/usedeviceconfigmodal.tsx","./src/pages/users/userprofile/userdevices/hooks/useeditdevicemodal.ts","./src/pages/users/userprofile/userdevices/modals/deleteuserdevicemodal/deleteuserdevicemodal.tsx","./src/pages/users/userprofile/userdevices/modals/deviceconfigmodal/deviceconfigmodal.tsx","./src/pages/users/userprofile/userdevices/modals/edituserdevicemodal/edituserdevicemodal.tsx","./src/pages/users/userprofile/userdevices/modals/edituserdevicemodal/userdeviceeditform.tsx","./src/pages/users/usersoverview/usersoverview.tsx","./src/pages/users/usersoverview/components/usereditbutton/resetpasswordbutton.tsx","./src/pages/users/usersoverview/components/usereditbutton/usereditbutton.tsx","./src/pages/users/usersoverview/components/userslist/userslist.tsx","./src/pages/users/usersoverview/components/userslist/types.ts","./src/pages/users/usersoverview/components/userslist/components/userlistrow.tsx","./src/pages/users/usersoverview/components/userslist/components/userslistgroups.tsx","./src/pages/users/usersoverview/components/userslist/modals/usergroupslistmodal/usergroupslistmodal.tsx","./src/pages/users/usersoverview/components/userslist/modals/usergroupslistmodal/useusergroupslistmodal.ts","./src/pages/users/usersoverview/modals/addusermodal/addusermodal.tsx","./src/pages/users/usersoverview/modals/addusermodal/components/adduserform/adduserform.tsx","./src/pages/users/usersoverview/modals/addusermodal/components/enrollmenttokencard/enrollmenttokencard.tsx","./src/pages/users/usersoverview/modals/addusermodal/components/startenrollmentform/startenrollmentform.tsx","./src/pages/users/usersoverview/modals/addusermodal/hooks/useaddusermodal.tsx","./src/pages/users/usersoverview/modals/assigngroupsmodal/assigngroupsmodal.tsx","./src/pages/users/usersoverview/modals/assigngroupsmodal/store.ts","./src/pages/users/shared/components/addcomponentbox/addbuttonicon.tsx","./src/pages/users/shared/components/addcomponentbox/addcomponentbox.tsx","./src/pages/users/shared/components/keybox/keybox.tsx","./src/pages/users/shared/modals/addapitokenmodal/addapitokenmodal.tsx","./src/pages/users/shared/modals/addapitokenmodal/useaddapitokenmodal.ts","./src/pages/users/shared/modals/addapitokenmodal/components/addapitokenform/addapitokenform.tsx","./src/pages/users/shared/modals/addauthenticationkeymodal/addauthenticationkeymodal.tsx","./src/pages/users/shared/modals/addauthenticationkeymodal/types.ts","./src/pages/users/shared/modals/addauthenticationkeymodal/useaddauthorizationkeymodal.ts","./src/pages/users/shared/modals/addauthenticationkeymodal/components/addauthenticationkeyform/addauthenticationkeyform.tsx","./src/pages/users/shared/modals/addauthenticationkeymodal/components/addauthenticationkeyyubikey/addauthenticationkeyyubikey.tsx","./src/pages/users/shared/modals/addauthenticationkeymodal/components/addauthenticationkeyyubikey/components/provisionerrow.tsx","./src/pages/users/shared/modals/changeuserpasswordmodal/changepasswordform.tsx","./src/pages/users/shared/modals/changeuserpasswordmodal/changeuserpasswordmodal.tsx","./src/pages/users/shared/modals/deleteusermodal/deleteusermodal.tsx","./src/pages/users/shared/modals/disablemfamodal/disablemfamodal.tsx","./src/pages/users/shared/modals/disablemfamodal/store.ts","./src/pages/users/shared/modals/renameapitokenmodal/renameapitokenmodal.tsx","./src/pages/users/shared/modals/renameapitokenmodal/userenameapitokenmodal.tsx","./src/pages/users/shared/modals/renameauthenticationkeymodal/renameauthenticationkeymodal.tsx","./src/pages/users/shared/modals/renameauthenticationkeymodal/userenameauthenticationkeymodal.tsx","./src/pages/users/shared/modals/toggleusermodal/toggleusermodal.tsx","./src/pages/webhooks/webhookslistpage.tsx","./src/pages/webhooks/modals/webhookmodal/webhookform.tsx","./src/pages/webhooks/modals/webhookmodal/webhookmodal.tsx","./src/pages/wizard/wizardpage.tsx","./src/pages/wizard/components/wizardmapdevices/wizardmapdevices.tsx","./src/pages/wizard/components/wizardmapdevices/components/mapdevicerow.tsx","./src/pages/wizard/components/wizardnav/wizardnav.tsx","./src/pages/wizard/components/wizardnetworkconfiguration/wizardnetworkconfiguration.tsx","./src/pages/wizard/components/wizardnetworkimport/wizardnetworkimport.tsx","./src/pages/wizard/components/wizardtype/wizardtype.tsx","./src/pages/wizard/components/wizardtype/components/wizardtypeoptioncard/wizardtypeoptioncard.tsx","./src/pages/wizard/components/wizardwelcome/wizardwelcome.tsx","./src/pages/wizard/hooks/usewizardstore.ts","./src/pages/wizard/types/interfaces.ts","./src/pages/wizard/types/types.ts","./src/shared/constants.ts","./src/shared/links.ts","./src/shared/messageids.ts","./src/shared/mutations.ts","./src/shared/patterns.ts","./src/shared/queries.ts","./src/shared/query-client.ts","./src/shared/types.ts","./src/shared/validators.ts","./src/shared/variants.ts","./src/shared/components/form/formacldefaultpolicyselect/formacldefaultpolicy.tsx","./src/shared/components/layout/copyfield/copyfield.tsx","./src/shared/components/layout/dateinput/dateinput.tsx","./src/shared/components/layout/dateinput/formdateinput.tsx","./src/shared/components/layout/dateinput/types.ts","./src/shared/components/layout/enterpriseupgradetoast/enterpriseupgradetoast.tsx","./src/shared/components/layout/enterpriseupgradetoast/types.ts","./src/shared/components/layout/expandablesection/expandablesection.tsx","./src/shared/components/layout/listcelltags/listcelltags.tsx","./src/shared/components/layout/listcelltext/listcelltext.tsx","./src/shared/components/layout/listheader/listheader.tsx","./src/shared/components/layout/listheader/types.ts","./src/shared/components/layout/managementpagelayout/managementpagelayout.tsx","./src/shared/components/layout/managementpagelayout/types.ts","./src/shared/components/layout/pagecontainer/pagecontainer.tsx","./src/shared/components/layout/pagelayout/pagelayout.tsx","./src/shared/components/layout/pagelimiter/pagelimiter.tsx","./src/shared/components/layout/rendermarkdown/rendermarkdown.tsx","./src/shared/components/layout/sectionwithcard/sectionwithcard.tsx","./src/shared/components/layout/upgradelicensemodal/upgradelicensemodal.tsx","./src/shared/components/layout/upgradelicensemodal/store.tsx","./src/shared/components/layout/upgradelicensemodal/types.ts","./src/shared/components/layout/versionupdatetoast/versionupdatetoast.tsx","./src/shared/components/layout/versionupdatetoast/types.ts","./src/shared/components/layout/wireguardconfigexpandable/wireguardconfigexpandable.tsx","./src/shared/components/layout/buttons/addbutton/addbutton.tsx","./src/shared/components/layout/buttons/filterbutton/filterbutton.tsx","./src/shared/components/router/guards/protectedroute/protectedroute.tsx","./src/shared/components/i18n/rendertranslation/rendertranslation.tsx","./src/shared/components/modals/filtergroupsmodal/filtergroupsmodal.tsx","./src/shared/components/modals/filtergroupsmodal/types.ts","./src/shared/components/modals/updatenotificationmodal/updatenotificationmodal.tsx","./src/shared/components/modals/updatenotificationmodal/components/updatenotificationmodalicons.tsx","./src/shared/components/network/deviceconfigscard/deviceconfigscard.tsx","./src/shared/components/network/deviceconfigscard/types.ts","./src/shared/components/network/gatewaysstatus/gatewaystatusicon.tsx","./src/shared/components/network/gatewaysstatus/types.ts","./src/shared/components/network/gatewaysstatus/allnetworksgatewaysstatus/allnetworksgatewaysstatus.tsx","./src/shared/components/network/gatewaysstatus/gatewaysfloatingstatus/gatewaysfloatingstatus.tsx","./src/shared/components/network/gatewaysstatus/gatewaysstatusinfo/gatewaysstatusinfo.tsx","./src/shared/components/network/gatewaysstatus/networkgatewaysstatus/networkgatewaysstatus.tsx","./src/shared/components/svg/avatar01blue.tsx","./src/shared/components/svg/avatar01gray.tsx","./src/shared/components/svg/avatar02blue.tsx","./src/shared/components/svg/avatar02gray.tsx","./src/shared/components/svg/avatar03blue.tsx","./src/shared/components/svg/avatar03gray.tsx","./src/shared/components/svg/avatar04blue.tsx","./src/shared/components/svg/avatar04gray.tsx","./src/shared/components/svg/avatar05blue.tsx","./src/shared/components/svg/avatar05gray.tsx","./src/shared/components/svg/avatar06blue.tsx","./src/shared/components/svg/avatar06gray.tsx","./src/shared/components/svg/avatar07blue.tsx","./src/shared/components/svg/avatar07gray.tsx","./src/shared/components/svg/avatar08blue.tsx","./src/shared/components/svg/avatar08gray.tsx","./src/shared/components/svg/avatar09blue.tsx","./src/shared/components/svg/avatar09gray.tsx","./src/shared/components/svg/avatar10blue.tsx","./src/shared/components/svg/avatar10gray.tsx","./src/shared/components/svg/avatar11blue.tsx","./src/shared/components/svg/avatar11gray.tsx","./src/shared/components/svg/avatar12blue.tsx","./src/shared/components/svg/avatar12gray.tsx","./src/shared/components/svg/defguardlogo.tsx","./src/shared/components/svg/defguardlogologin.tsx","./src/shared/components/svg/defguardnavlogo.tsx","./src/shared/components/svg/defguardnavlogocollapsed.tsx","./src/shared/components/svg/defguardnoicon.tsx","./src/shared/components/svg/glowicon.tsx","./src/shared/components/svg/icon24hconnections.tsx","./src/shared/components/svg/iconactiveconnections.tsx","./src/shared/components/svg/iconactivityadd.tsx","./src/shared/components/svg/iconactivityremoved.tsx","./src/shared/components/svg/iconactivitywarning.tsx","./src/shared/components/svg/iconarrowdouble.tsx","./src/shared/components/svg/iconarrowdoublegrayleft.tsx","./src/shared/components/svg/iconarrowgraydown.tsx","./src/shared/components/svg/iconarrowgraydown1.tsx","./src/shared/components/svg/iconarrowgrayleft.tsx","./src/shared/components/svg/iconarrowgrayright.tsx","./src/shared/components/svg/iconarrowgraysmall.tsx","./src/shared/components/svg/iconarrowgrayup.tsx","./src/shared/components/svg/iconarrowgrayup1.tsx","./src/shared/components/svg/iconarrowsingle.tsx","./src/shared/components/svg/iconarrowsingle2.tsx","./src/shared/components/svg/iconarrowwhiteleft.tsx","./src/shared/components/svg/iconasterix.tsx","./src/shared/components/svg/iconauthenticationkey.tsx","./src/shared/components/svg/iconcancel.tsx","./src/shared/components/svg/iconcancelalt.tsx","./src/shared/components/svg/iconcheckmark.tsx","./src/shared/components/svg/iconcheckmarkgreen.tsx","./src/shared/components/svg/iconcheckmarkwhite.tsx","./src/shared/components/svg/iconcheckmarkwhite1.tsx","./src/shared/components/svg/iconcheckmarkwhitebig.tsx","./src/shared/components/svg/iconclip.tsx","./src/shared/components/svg/iconclosegray.tsx","./src/shared/components/svg/iconcollapse.tsx","./src/shared/components/svg/iconconnected.tsx","./src/shared/components/svg/iconcopy.tsx","./src/shared/components/svg/icondeactivated.tsx","./src/shared/components/svg/icondelete.tsx","./src/shared/components/svg/icondfgopenidredirect.tsx","./src/shared/components/svg/icondisconnected.tsx","./src/shared/components/svg/icondownload.tsx","./src/shared/components/svg/iconedit.tsx","./src/shared/components/svg/iconeditalt.tsx","./src/shared/components/svg/iconeditalthover.tsx","./src/shared/components/svg/iconedithover.tsx","./src/shared/components/svg/iconeditnetwork.tsx","./src/shared/components/svg/iconeth.tsx","./src/shared/components/svg/iconexpand.tsx","./src/shared/components/svg/iconfilter.tsx","./src/shared/components/svg/iconhamburgerclose.tsx","./src/shared/components/svg/iconhamburgermenu.tsx","./src/shared/components/svg/iconhamburgermenu1.tsx","./src/shared/components/svg/iconhourglass.tsx","./src/shared/components/svg/iconhourglasshover.tsx","./src/shared/components/svg/iconinfo.tsx","./src/shared/components/svg/iconinfoerror.tsx","./src/shared/components/svg/iconinfonormal.tsx","./src/shared/components/svg/iconinfosuccess.tsx","./src/shared/components/svg/iconinfosuccess1.tsx","./src/shared/components/svg/iconinfowarning.tsx","./src/shared/components/svg/iconkey.tsx","./src/shared/components/svg/iconlistorderdown.tsx","./src/shared/components/svg/iconlistorderdownhover.tsx","./src/shared/components/svg/iconlistorderup.tsx","./src/shared/components/svg/iconlistorderuphover.tsx","./src/shared/components/svg/iconnavgroups.tsx","./src/shared/components/svg/iconnavhamburger.tsx","./src/shared/components/svg/iconnavkey.tsx","./src/shared/components/svg/iconnavlocations.tsx","./src/shared/components/svg/iconnavlogout.tsx","./src/shared/components/svg/iconnavopenid.tsx","./src/shared/components/svg/iconnavoverview.tsx","./src/shared/components/svg/iconnavprofile.tsx","./src/shared/components/svg/iconnavprofile1.tsx","./src/shared/components/svg/iconnavprovisioners.tsx","./src/shared/components/svg/iconnavsettings.tsx","./src/shared/components/svg/iconnavsupport.tsx","./src/shared/components/svg/iconnavusers.tsx","./src/shared/components/svg/iconnavvpn.tsx","./src/shared/components/svg/iconnavwebhook.tsx","./src/shared/components/svg/iconnavwebhooks.tsx","./src/shared/components/svg/iconnavyubikey.tsx","./src/shared/components/svg/iconnetworkload.tsx","./src/shared/components/svg/iconopenmodal.tsx","./src/shared/components/svg/iconpacketsin.tsx","./src/shared/components/svg/iconpacketsout.tsx","./src/shared/components/svg/iconplusgray.tsx","./src/shared/components/svg/iconpluswhite.tsx","./src/shared/components/svg/iconpopupclose.tsx","./src/shared/components/svg/iconreadmore.tsx","./src/shared/components/svg/iconredirect.tsx","./src/shared/components/svg/iconsearch.tsx","./src/shared/components/svg/iconsearchhover.tsx","./src/shared/components/svg/iconsettings.tsx","./src/shared/components/svg/iconsuccesslarge.tsx","./src/shared/components/svg/icontagdismiss.tsx","./src/shared/components/svg/icontrash.tsx","./src/shared/components/svg/iconupgrade.tsx","./src/shared/components/svg/iconuseraddnew.tsx","./src/shared/components/svg/iconuserlist.tsx","./src/shared/components/svg/iconuserlistelement.tsx","./src/shared/components/svg/iconuserlistexpanded.tsx","./src/shared/components/svg/iconuserlisthover.tsx","./src/shared/components/svg/iconwaiting.tsx","./src/shared/components/svg/iconwaitinghover.tsx","./src/shared/components/svg/iconwallet.tsx","./src/shared/components/svg/iconwarning.tsx","./src/shared/components/svg/iconx.tsx","./src/shared/components/svg/imagemeshnetwork.tsx","./src/shared/components/svg/imageregularnetwork.tsx","./src/shared/components/svg/importconfig.tsx","./src/shared/components/svg/logodefguardwhite.tsx","./src/shared/components/svg/manualconfig.tsx","./src/shared/components/svg/metamaskicon.tsx","./src/shared/components/svg/phantomicon.tsx","./src/shared/components/svg/qriconwhite.tsx","./src/shared/components/svg/subtract.tsx","./src/shared/components/svg/wireguardlogo.tsx","./src/shared/components/svg/yubikeyprovisioninggraphic.tsx","./src/shared/components/utils/delayrender/delayrender.tsx","./src/shared/defguard-ui/components/form/formcheckbox/formcheckbox.tsx","./src/shared/defguard-ui/components/form/formdevtools/formdevtools.tsx","./src/shared/defguard-ui/components/form/forminput/forminput.tsx","./src/shared/defguard-ui/components/form/formlocationip/formlocationip.tsx","./src/shared/defguard-ui/components/form/formlocationip/type.ts","./src/shared/defguard-ui/components/form/formselect/formselect.tsx","./src/shared/defguard-ui/components/form/formtextarea/formtextarea.tsx","./src/shared/defguard-ui/components/form/formtoggle/formtoggle.tsx","./src/shared/defguard-ui/components/layout/actionbutton/actionbutton.tsx","./src/shared/defguard-ui/components/layout/actionbutton/types.ts","./src/shared/defguard-ui/components/layout/actionbutton/icons/actioniconconfig.tsx","./src/shared/defguard-ui/components/layout/activitystatus/activitystatus.tsx","./src/shared/defguard-ui/components/layout/activitystatus/types.ts","./src/shared/defguard-ui/components/layout/avatarbox/avatarbox.tsx","./src/shared/defguard-ui/components/layout/badge/badge.tsx","./src/shared/defguard-ui/components/layout/badge/types.ts","./src/shared/defguard-ui/components/layout/biginfobox/biginfobox.tsx","./src/shared/defguard-ui/components/layout/button/button.tsx","./src/shared/defguard-ui/components/layout/button/types.ts","./src/shared/defguard-ui/components/layout/card/card.tsx","./src/shared/defguard-ui/components/layout/cardtabs/cardtabs.tsx","./src/shared/defguard-ui/components/layout/cardtabs/types.ts","./src/shared/defguard-ui/components/layout/cardtabs/components/cardtab.tsx","./src/shared/defguard-ui/components/layout/checkbox/checkbox.tsx","./src/shared/defguard-ui/components/layout/checkbox/types.ts","./src/shared/defguard-ui/components/layout/deviceavatar/deviceavatar.tsx","./src/shared/defguard-ui/components/layout/deviceavatar/utils/getdeviceavatar.ts","./src/shared/defguard-ui/components/layout/divider/divider.tsx","./src/shared/defguard-ui/components/layout/divider/types.ts","./src/shared/defguard-ui/components/layout/editbutton/editbutton.tsx","./src/shared/defguard-ui/components/layout/editbutton/editbuttonoption.tsx","./src/shared/defguard-ui/components/layout/editbutton/types.ts","./src/shared/defguard-ui/components/layout/expandablecard/expandablecard.tsx","./src/shared/defguard-ui/components/layout/fielderror/fielderror.tsx","./src/shared/defguard-ui/components/layout/floatingarrow/floatingarrow.tsx","./src/shared/defguard-ui/components/layout/floatingbox/floatingbox.tsx","./src/shared/defguard-ui/components/layout/floatingmenu/floatingmenu.tsx","./src/shared/defguard-ui/components/layout/floatingmenu/floatingmenuarrow.tsx","./src/shared/defguard-ui/components/layout/floatingmenu/floatingmenuprovider.tsx","./src/shared/defguard-ui/components/layout/floatingmenu/floatingmenutrigger.tsx","./src/shared/defguard-ui/components/layout/floatingmenu/types.ts","./src/shared/defguard-ui/components/layout/floatingmenu/usefloatingmenucontext.tsx","./src/shared/defguard-ui/components/layout/helper/helper.tsx","./src/shared/defguard-ui/components/layout/iconcontainer/iconcontainer.tsx","./src/shared/defguard-ui/components/layout/input/input.tsx","./src/shared/defguard-ui/components/layout/input/types.ts","./src/shared/defguard-ui/components/layout/interactionbox/interactionbox.tsx","./src/shared/defguard-ui/components/layout/label/label.tsx","./src/shared/defguard-ui/components/layout/labeledcheckbox/labeledcheckbox.tsx","./src/shared/defguard-ui/components/layout/limitedtext/limitedtext.tsx","./src/shared/defguard-ui/components/layout/listitemcount/listitemcount.tsx","./src/shared/defguard-ui/components/layout/loaderspinner/loaderspinner.tsx","./src/shared/defguard-ui/components/layout/messagebox/messagebox.tsx","./src/shared/defguard-ui/components/layout/messagebox/types.ts","./src/shared/defguard-ui/components/layout/messagebox/utils.ts","./src/shared/defguard-ui/components/layout/networkspeed/networkspeed.tsx","./src/shared/defguard-ui/components/layout/networkspeed/types.ts","./src/shared/defguard-ui/components/layout/nodata/nodata.tsx","./src/shared/defguard-ui/components/layout/radiobutton/radiobutton.tsx","./src/shared/defguard-ui/components/layout/rowbox/rowbox.tsx","./src/shared/defguard-ui/components/layout/search/search.tsx","./src/shared/defguard-ui/components/layout/select/select.tsx","./src/shared/defguard-ui/components/layout/select/types.ts","./src/shared/defguard-ui/components/layout/select/components/resizableinput/resizableinput.tsx","./src/shared/defguard-ui/components/layout/select/components/selectoptionrow/selectoptionrow.tsx","./src/shared/defguard-ui/components/layout/selectrow/selectrow.tsx","./src/shared/defguard-ui/components/layout/tag/tag.tsx","./src/shared/defguard-ui/components/layout/textcontainer/textcontainer.tsx","./src/shared/defguard-ui/components/layout/textarea/textarea.tsx","./src/shared/defguard-ui/components/layout/textarea/types.ts","./src/shared/defguard-ui/components/layout/textareaautoresizable/textareaautoresizable.tsx","./src/shared/defguard-ui/components/layout/textareaautoresizable/types.ts","./src/shared/defguard-ui/components/layout/toastmanager/toastmanager.tsx","./src/shared/defguard-ui/components/layout/toastmanager/toast/toast.tsx","./src/shared/defguard-ui/components/layout/toastmanager/toast/types.ts","./src/shared/defguard-ui/components/layout/toggle/toggle.tsx","./src/shared/defguard-ui/components/layout/toggle/types.ts","./src/shared/defguard-ui/components/layout/toggle/components/toggleoption/toggleoption.tsx","./src/shared/defguard-ui/components/layout/userinitials/userinitials.tsx","./src/shared/defguard-ui/components/layout/virtualizedlist/virtualizedlist.tsx","./src/shared/defguard-ui/components/layout/virtualizedlist/virtualizedlistsorticon.tsx","./src/shared/defguard-ui/components/layout/virtualizedlist/types.ts","./src/shared/defguard-ui/components/layout/modals/confirmmodal/confirmmodal.tsx","./src/shared/defguard-ui/components/layout/modals/confirmmodal/types.ts","./src/shared/defguard-ui/components/layout/modals/modal/modal.tsx","./src/shared/defguard-ui/components/layout/modals/modal/types.ts","./src/shared/defguard-ui/components/layout/modals/modalwithtitle/modalwithtitle.tsx","./src/shared/defguard-ui/components/icons/activityicon/activityicon.tsx","./src/shared/defguard-ui/components/icons/activityicon/types.ts","./src/shared/defguard-ui/components/icons/arrowsingle/arrowsingle.tsx","./src/shared/defguard-ui/components/icons/arrowsingle/types.ts","./src/shared/defguard-ui/components/svg/avatar01.tsx","./src/shared/defguard-ui/components/svg/avatar02.tsx","./src/shared/defguard-ui/components/svg/avatar03.tsx","./src/shared/defguard-ui/components/svg/avatar04.tsx","./src/shared/defguard-ui/components/svg/avatar05.tsx","./src/shared/defguard-ui/components/svg/avatar06.tsx","./src/shared/defguard-ui/components/svg/avatar07.tsx","./src/shared/defguard-ui/components/svg/avatar08.tsx","./src/shared/defguard-ui/components/svg/avatar09.tsx","./src/shared/defguard-ui/components/svg/avatar10.tsx","./src/shared/defguard-ui/components/svg/avatar11.tsx","./src/shared/defguard-ui/components/svg/avatar12.tsx","./src/shared/defguard-ui/components/svg/checkboxchecked.tsx","./src/shared/defguard-ui/components/svg/checkboxunchecked.tsx","./src/shared/defguard-ui/components/svg/iconarrowsinglelarge.tsx","./src/shared/defguard-ui/components/svg/iconarrowsinglesmall.tsx","./src/shared/defguard-ui/components/svg/iconasterix.tsx","./src/shared/defguard-ui/components/svg/iconcancel.tsx","./src/shared/defguard-ui/components/svg/iconconnection.tsx","./src/shared/defguard-ui/components/svg/iconcopy.tsx","./src/shared/defguard-ui/components/svg/icondownload.tsx","./src/shared/defguard-ui/components/svg/iconhamburgerdotted.tsx","./src/shared/defguard-ui/components/svg/iconhamburgernav.tsx","./src/shared/defguard-ui/components/svg/iconinfo.tsx","./src/shared/defguard-ui/components/svg/iconinfosuccess.tsx","./src/shared/defguard-ui/components/svg/iconloupe.tsx","./src/shared/defguard-ui/components/svg/iconoutsidelink.tsx","./src/shared/defguard-ui/components/svg/iconplus.tsx","./src/shared/defguard-ui/components/svg/iconqr.tsx","./src/shared/defguard-ui/components/svg/iconsettings.tsx","./src/shared/defguard-ui/components/svg/iconstatus.tsx","./src/shared/defguard-ui/components/svg/iconstatusblank.tsx","./src/shared/defguard-ui/components/svg/iconwarning.tsx","./src/shared/defguard-ui/components/svg/iconx.tsx","./src/shared/defguard-ui/hooks/usesize.tsx","./src/shared/defguard-ui/hooks/theme/types.ts","./src/shared/defguard-ui/hooks/theme/usetheme.tsx","./src/shared/defguard-ui/hooks/theme/utils.ts","./src/shared/defguard-ui/hooks/toasts/usetoaststore.ts","./src/shared/defguard-ui/hooks/toasts/usetoaster.tsx","./src/shared/defguard-ui/utils/detectclickoutside.ts","./src/shared/defguard-ui/utils/iscomparable.ts","./src/shared/defguard-ui/utils/ispresent.ts","./src/shared/helpers/displaydate.ts","./src/shared/helpers/getuserfullname.ts","./src/shared/helpers/useeffectonce.ts","./src/shared/hooks/useapi.tsx","./src/shared/hooks/useclipboard.tsx","./src/shared/hooks/usetoaster.tsx","./src/shared/hooks/api/api-client.ts","./src/shared/hooks/api/api.ts","./src/shared/hooks/api/axios-client.ts","./src/shared/hooks/api/provider.tsx","./src/shared/hooks/api/store.ts","./src/shared/hooks/store/useappstore.ts","./src/shared/hooks/store/useauthstore.ts","./src/shared/hooks/store/useenterpriseupgradestore.tsx","./src/shared/hooks/store/usemodalstore.ts","./src/shared/hooks/store/useopenidclientstore.tsx","./src/shared/hooks/store/useupdatesstore.tsx","./src/shared/hooks/store/useuserprofilestore.ts","./src/shared/utils/chainname.ts","./src/shared/utils/checkplatform.ts","./src/shared/utils/convertfromstringtobuffer.ts","./src/shared/utils/detectclickoutside.ts","./src/shared/utils/downloadwgconfig.ts","./src/shared/utils/extractinitials.ts","./src/shared/utils/generatewgkeys.ts","./src/shared/utils/invalidatemultiplequeries.ts","./src/shared/utils/localetodatepicker.ts","./src/shared/utils/omitnull.ts","./src/shared/utils/removeemptystrings.ts","./src/shared/utils/removenulls.ts","./src/shared/utils/searchbykeys.ts","./src/shared/utils/sortbydate.ts","./src/shared/utils/stringtoblob.ts","./src/shared/utils/titlecase.ts","./src/shared/utils/trimobjectstrings.ts","./src/shared/utils/form/selectifynetwork.ts","./src/shared/validators/password.ts"],"errors":true,"version":"5.8.3"} \ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json index 65f670c8f0..7a38057a8d 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,5 +1,8 @@ { "files": [], + "exclude": [ + "./src/i18n/**" + ], "references": [ { "path": "./tsconfig.app.json" @@ -8,4 +11,4 @@ "path": "./tsconfig.node.json" } ] -} \ No newline at end of file +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json index 6513433be4..0656354733 100644 --- a/web/tsconfig.node.json +++ b/web/tsconfig.node.json @@ -17,10 +17,10 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, + "noFallthroughCasesInSwitch": true // "noUncheckedSideEffectImports": true }, "include": [ "vite.config.mts" ] -} \ No newline at end of file +}