[
  {
    "path": ".all-contributorsrc",
    "content": "{\n  \"projectName\": \"warpgate\",\n  \"projectOwner\": \"warp-tech\",\n  \"repoType\": \"github\",\n  \"repoHost\": \"https://github.com\",\n  \"files\": [\n    \"README.md\"\n  ],\n  \"imageSize\": 100,\n  \"commit\": true,\n  \"commitConvention\": \"none\",\n  \"contributors\": [\n    {\n      \"login\": \"Eugeny\",\n      \"name\": \"Eugeny\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/161476?v=4\",\n      \"profile\": \"https://github.com/Eugeny\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"heywoodlh\",\n      \"name\": \"Spencer Heywood\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/18178614?v=4\",\n      \"profile\": \"https://the-empire.systems/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"apiening\",\n      \"name\": \"Andreas Piening\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/2064875?v=4\",\n      \"profile\": \"https://github.com/apiening\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Gurkengewuerz\",\n      \"name\": \"Niklas\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/10966337?v=4\",\n      \"profile\": \"https://github.com/Gurkengewuerz\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"notnooblord\",\n      \"name\": \"Nooblord\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/11678665?v=4\",\n      \"profile\": \"https://github.com/notnooblord\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"SheaSmith\",\n      \"name\": \"Shea Smith\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/51303984?v=4\",\n      \"profile\": \"https://shea.nz/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"samtoxie\",\n      \"name\": \"samtoxie\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/7732658?v=4\",\n      \"profile\": \"https://github.com/samtoxie\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"pfoundation\",\n      \"name\": \"P Foundation\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/80860929?v=4\",\n      \"profile\": \"https://p.foundation/\",\n      \"contributions\": [\n        \"financial\"\n      ]\n    },\n    {\n      \"login\": \"alairock\",\n      \"name\": \"Skyler Lewis\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1480236?v=4\",\n      \"profile\": \"http://sixteenink.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"MohammedNoureldin\",\n      \"name\": \"Mohammed Noureldin\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/14913147?v=4\",\n      \"profile\": \"http://www.mohammednoureldin.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"mrmm\",\n      \"name\": \"Mourad Maatoug\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/796467?v=4\",\n      \"profile\": \"https://github.com/mrmm\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"justinforlenza\",\n      \"name\": \"Justin\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/11709872?v=4\",\n      \"profile\": \"http://justinforlenza.dev\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"liebermantodd\",\n      \"name\": \"liebermantodd\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/12155811?v=4\",\n      \"profile\": \"https://github.com/liebermantodd\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"cvhariharan\",\n      \"name\": \"Hariharan\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/14965074?v=4\",\n      \"profile\": \"https://blog.trieoflogs.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"solidassassin\",\n      \"name\": \"Rokas Krivaitis\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/47082246?v=4\",\n      \"profile\": \"https://github.com/solidassassin\",\n      \"contributions\": [\n        \"code\"\n      ]\n    }\n  ],\n  \"contributorsPerLine\": 7,\n  \"commitType\": \"docs\"\n}\n"
  },
  {
    "path": ".bumpversion.cfg",
    "content": "[bumpversion]\ncurrent_version = 0.22.0-beta.2\ncommit = True\ntag = True\n\n[bumpversion:file:warpgate/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-admin/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-ca/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-common/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-common-http/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-core/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-database-protocols/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-db-entities/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-db-migrations/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-protocol-kubernetes/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-ldap/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-protocol-http/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-protocol-mysql/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-protocol-postgres/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-protocol-ssh/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-tls/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-sso/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n\n[bumpversion:file:warpgate-web/Cargo.toml]\nsearch = version = \"{current_version}\"\nreplace = version = \"{new_version}\"\n"
  },
  {
    "path": ".cargo/config.toml",
    "content": "# https://github.com/rust-lang/cargo/issues/5376#issuecomment-2163350032\n[target.'cfg(all())']\nrustflags = [\n    \"--cfg\", \"tokio_unstable\",\n    \"-Zremap-cwd-prefix=/reproducible-cwd\",\n    \"--remap-path-prefix=$HOME=/reproducible-home\",\n    \"--remap-path-prefix=$PWD=/reproducible-pwd\",\n]\n"
  },
  {
    "path": ".dockerignore",
    "content": "data*\ncdx\ndhap-heap.json\n.pytest_cache\n\n# Generated by Cargo\n# will have compiled files and executables\ntarget\n*/target\n\n# These are backup files generated by rustfmt\n**/*.rs.bk\n\n# MSVC Windows builds of rustc generate these, which store debugging information\n*.pdb\n\ntemp\nhost_key*\n.vscode\n\n# ---\n\ndata\nconfig.*.yaml\nconfig.yaml\n\nwarpgate-web/dist\nwarpgate-web/node_modules\nwarpgate-web/src/admin/lib/api-client/\nwarpgate-web/src/gateway/lib/api-client/\n"
  },
  {
    "path": ".flake8",
    "content": "[flake8]\nignore=E501,D103,C901,D203,W504,S607,S603,S404,S606,S322,S410,S320,B010\nexclude = .git,__pycache__,help,static,misc,locale,templates,tests,deployment,migrations,elements/ai/scripts\nmax-complexity = 40\nbuiltins = _\nper-file-ignores = scripts/*:T001,E402\nselect = C,E,F,W,B,B902\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: eugeny\nopen_collective: tabby\nko_fi: eugeny\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# Please see the documentation for all configuration options:\n# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"cargo\"\n    directory: \"/\"\n    labels: [\"type/deps\"]\n    #open-pull-requests-limit: 25\n    schedule:\n      interval: \"daily\"\n    groups:\n      version-bumps:\n        applies-to: version-updates\n        update-types:\n          - minor\n          - patch\n  - package-ecosystem: \"npm\"\n    directory: \"/warpgate-web\"\n    labels: [\"type/deps\"]\n    #open-pull-requests-limit: 25\n    groups:\n      version-bumps:\n        applies-to: version-updates\n        update-types:\n          - minor\n          - patch\n    schedule:\n      interval: \"daily\"\n\n  - package-ecosystem: github-actions\n    directory: /\n    schedule:\n      interval: daily\n\n  - package-ecosystem: docker\n    directory: /docker\n    schedule:\n      interval: daily\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build\npermissions:\n  contents: read\n\non: [push, pull_request]\n\njobs:\n  build:\n    strategy:\n      matrix:\n        include:\n          - arch: x86_64-linux\n            target: x86_64-unknown-linux-gnu\n            os: ubuntu-22.04 # older image for glibc compatibility\n            cyclonedx-build: cyclonedx-linux-x64\n            cargo-cross: false\n          - arch: arm64-linux\n            target: aarch64-unknown-linux-gnu\n            os: ubuntu-22.04-arm # older image for glibc compatibility\n            cyclonedx-build: cyclonedx-linux-arm64\n            cargo-cross: false\n          - arch: x86_64-macos\n            target: x86_64-apple-darwin\n            os: macos-latest\n            cyclonedx-build: cyclonedx-osx-x64\n            cargo-cross: false\n          - arch: arm64-macos\n            target: aarch64-apple-darwin\n            os: macos-latest\n            cyclonedx-build: cyclonedx-osx-arm64\n            cargo-cross: true\n      fail-fast: false\n\n    name: Build (${{ matrix.arch }})\n    runs-on: ${{ matrix.os }}\n    permissions:\n      contents: write\n      id-token: write\n      attestations: write\n      artifact-metadata: write\n\n    steps:\n      - if: startsWith(matrix.os, 'ubuntu')\n        run: |\n          sudo apt update\n          sudo apt install -y libssl-dev pkg-config\n\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          submodules: recursive\n\n      - uses: rlespinasse/github-slug-action@9e7def61550737ba68c62d34a32dd31792e3f429\n\n      - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af\n        with:\n          target: ${{ matrix.target }}\n          override: true\n\n      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4\n        with:\n          key: \"build\"\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f\n        with:\n          node-version: 24\n\n      - name: Install tools\n        run: |\n          cargo install --locked just\n          cargo install --locked cargo-deny@0.18.9\n          cargo install --locked cargo-cyclonedx@^0.5\n          rm -rf ~/.cargo/registry\n\n      - name: Install CycloneDX\n        run: |\n          mkdir cdx\n          wget https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.27.2/${{ matrix.cyclonedx-build }} -O cyclonedx\n          chmod +x cyclonedx\n\n      - name: Install CycloneDX-npm\n        run: |\n          cd /\n          npm i -g @cyclonedx/cyclonedx-npm@4.1.2\n\n      - name: cargo-deny\n        run: |\n          cargo deny --version\n          cargo deny check\n\n      - name: Install admin UI deps\n        run: |\n          just npm ci\n\n      - name: Build admin UI\n        run: |\n          just npm run build\n\n      - name: Generate admin UI BOM\n        run: |\n          just npm prune\n          cd warpgate-web && NODE_ENV=dev cyclonedx-npm --output-format xml > ../cdx/admin-ui.cdx.xml\n\n      - name: Build\n        uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505\n        with:\n          command: build\n          use-cross: ${{ matrix.cargo-cross }}\n          args: --all-features --release --target ${{ matrix.target }}\n        env:\n          ENV SOURCE_DATE_EPOCH: \"0\" # for rust-embed determinism\n          CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: \"--cfg tokio_unstable --remap-path-prefix=$HOME=/reproducible-home --remap-path-prefix=$PWD=/reproducible-pwd\"\n          CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: \"--cfg tokio_unstable --remap-path-prefix=$HOME=/reproducible-home --remap-path-prefix=$PWD=/reproducible-pwd\"\n          CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS: \"--cfg tokio_unstable --remap-path-prefix=$HOME=/reproducible-home --remap-path-prefix=$PWD=/reproducible-pwd\"\n          CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS: \"--cfg tokio_unstable --remap-path-prefix=$HOME=/reproducible-home --remap-path-prefix=$PWD=/reproducible-pwd\"\n\n      - name: Generate Rust BOM\n        run: |\n          cargo cyclonedx --all-features\n          mv warpgate*/*.cdx.xml cdx/\n\n      - name: Merge BOMs\n        run: ./cyclonedx merge --input-files cdx/* --input-format xml --output-format xml > cdx.xml\n\n      - name: Attest build\n        uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26\n        if: startsWith(github.ref, 'refs/tags/v')\n        with:\n          subject-path: target/${{ matrix.target }}/release/warpgate\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50\n        with:\n          name: warpgate-${{ env.GITHUB_REF_SLUG }}-${{ matrix.arch }}\n          path: target/${{ matrix.target }}/release/warpgate\n\n      - name: Upload SBOM\n        uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50\n        with:\n          name: warpgate-${{ env.GITHUB_REF_SLUG }}-${{ matrix.arch }}.cdx.xml\n          path: cdx.xml\n\n      - name: Rename artifacts\n        run: |\n          mkdir dist\n          mv target/${{ matrix.target }}/release/warpgate dist/warpgate-${{ env.GITHUB_REF_SLUG }}-${{ matrix.arch }}\n          mv cdx.xml dist/warpgate-${{ env.GITHUB_REF_SLUG }}-${{ matrix.arch }}.cdx.xml\n\n      - name: Upload release\n        uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe\n        if: startsWith(github.ref, 'refs/tags/v')\n        with:\n          draft: true\n          generate_release_notes: true\n          files: dist/*\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n  config-schema:\n    name: Config schema check\n    runs-on: ubuntu-24.04\n\n    steps:\n      - name: Setup\n        run: |\n          sudo apt update\n          sudo apt install --no-install-recommends -y libssl-dev pkg-config\n\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          submodules: recursive\n\n      - name: Install tools\n        run: |\n          cargo install --locked just\n\n      - name: Ensure there are no changes in config schema\n        run: |\n          mkdir warpgate-web/dist\n          just config-schema\n          git diff --exit-code config-schema.json\n"
  },
  {
    "path": ".github/workflows/check-schema-compatibility.yml",
    "content": "name: Check API Schema Compatibility\n\non: [pull_request]\npermissions:\n  contents: read\n\njobs:\n  check-schema-compatibility:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout PR branch\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          fetch-depth: 0\n\n      - name: Install just\n        run: |\n          cargo install just\n\n      - name: Install npm dependencies\n        run: |\n          cd warpgate-web && npm ci\n\n      - name: Generate API schema files\n        run: |\n          mkdir warpgate-web/dist\n          just openapi-all\n\n      - name: Checkout main branch to compare\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          ref: main\n          path: main-branch\n\n      - name: Check admin API\n        run: |\n          docker run --rm -t -v $(pwd):/specs:ro tufin/oasdiff breaking -f githubactions --fail-on WARN /specs/main-branch/warpgate-web/src/admin/lib/openapi-schema.json /specs/warpgate-web/src/admin/lib/openapi-schema.json\n\n      - name: Check gateway API\n        run: |\n          docker run --rm -t -v $(pwd):/specs:ro tufin/oasdiff breaking -f githubactions --fail-on WARN /specs/main-branch/warpgate-web/src/gateway/lib/openapi-schema.json /specs/warpgate-web/src/gateway/lib/openapi-schema.json\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL Advanced\"\npermissions:\n  contents: read\non:\n  push:\n    branches: [ \"main\" ]\n  pull_request:\n    branches: [ \"main\" ]\n  schedule:\n    - cron: '19 11 * * 0'\n\njobs:\n  analyze:\n    name: Analyze (${{ matrix.language }})\n    # Runner size impacts CodeQL analysis time. To learn more, please see:\n    #   - https://gh.io/recommended-hardware-resources-for-running-codeql\n    #   - https://gh.io/supported-runners-and-hardware-resources\n    #   - https://gh.io/using-larger-runners (GitHub.com only)\n    # Consider using larger runners or machines with greater resources for possible analysis time improvements.\n    runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}\n    permissions:\n      # required for all workflows\n      security-events: write\n\n      # required to fetch internal or private CodeQL packs\n      packages: read\n\n      # only required for workflows in private repositories\n      actions: read\n      contents: read\n\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n        - language: actions\n          build-mode: none\n        - language: javascript-typescript\n          build-mode: none\n        - language: rust\n          build-mode: none\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n\n    # Add any setup steps before running the `github/codeql-action/init` action.\n    # This includes steps like installing compilers or runtimes (`actions/setup-node`\n    # or others). This is typically only required for manual builds.\n    # - name: Setup runtime (example)\n    #   uses: actions/setup-example@v1\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8\n      with:\n        languages: ${{ matrix.language }}\n        build-mode: ${{ matrix.build-mode }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n\n        # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs\n        # queries: security-extended,security-and-quality\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8\n      with:\n        category: \"/language:${{matrix.language}}\"\n"
  },
  {
    "path": ".github/workflows/dependency-review.yml",
    "content": "# Dependency Review Action\n#\n# This Action will scan dependency manifest files that change as part of a Pull Reqest, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.\n#\n# Source repository: https://github.com/actions/dependency-review-action\n# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement\nname: 'Dependency Review'\non: [pull_request]\n\npermissions:\n  contents: read\n\njobs:\n  dependency-review:\n    runs-on: ubuntu-latest\n    steps:\n      - name: 'Checkout Repository'\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n      - name: 'Dependency Review'\n        uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Docker\npermissions: read-all\non:\n  schedule:\n    - cron: '25 12 * * *'\n  push:\n    branches: [ '**' ]\n    tags: [ 'v*.*.*' ] # Publish semver tags as releases.\n  pull_request:\n    branches: [ '**' ]\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: warp-tech/warpgate\n\njobs:\n  build:\n    runs-on: ${{matrix.os}}\n    strategy:\n      matrix:\n        include:\n          - os: ubuntu-24.04\n            docker-platform: linux/amd64\n            matrix-id: amd64\n          - os: ubuntu-24.04-arm\n            docker-platform: linux/arm64\n            matrix-id: arm64\n\n    permissions:\n      contents: read\n      packages: write\n      id-token: write\n      attestations: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          submodules: recursive\n          fetch-depth: 0\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd\n\n      - name: Log into registry ${{ env.REGISTRY }}\n        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository\n        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n\n      - name: Build Docker image without pushing\n        if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository\n        id: build-no-push\n        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294\n        with:\n          file: docker/Dockerfile\n          context: .\n          push: false\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: ${{ matrix.docker-platform }}\n          cache-from: type=gha,scope=build-${{ matrix.docker-platform }}\n\n      - name: Build and push Docker image\n        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository\n        id: build\n        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294\n        with:\n          file: docker/Dockerfile\n          context: .\n          push: true\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: ${{ matrix.docker-platform }}\n          cache-from: type=gha,scope=build-${{ matrix.docker-platform }}\n          cache-to: type=gha,scope=build-${{ matrix.docker-platform }}\n          outputs: type=image,\"name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\",push-by-digest=true,name-canonical=true,push=true\n          # Provenance attestations generates an unknown/unknown platform layer\n          # that causes issues, see #1255\n          attests: ''\n          provenance: false\n\n      - name: Export digest\n        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: Upload digest\n        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository\n        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02\n        with:\n          name: digests-${{ matrix.matrix-id }}\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  merge:\n    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository\n    runs-on: ubuntu-latest\n    needs:\n      - build\n    permissions:\n      contents: read\n      packages: write\n      id-token: write\n      attestations: write\n    steps:\n      - name: Download digests\n        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c\n        with:\n          path: ${{ runner.temp }}/digests\n          pattern: digests-*\n          merge-multiple: true\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd\n\n      - name: Log into registry ${{ env.REGISTRY }}\n        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=ref,event=branch,prefix=branch-\n            type=ref,event=pr,prefix=pr-\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=schedule\n\n      - name: Create manifest list and push\n        working-directory: ${{ runner.temp }}/digests\n        run: |\n          docker buildx imagetools create $(jq -cr '.tags | map(\"-t \" + .) | join(\" \")' <<< \"$DOCKER_METADATA_OUTPUT_JSON\") \\\n            $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)\n\n      - name: Inspect image\n        run: |\n          docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}\n"
  },
  {
    "path": ".github/workflows/reprotest.yml",
    "content": "name: Reproducibility test\npermissions:\n  contents: read\n\non: workflow_dispatch\n\njobs:\n  reprotest:\n    name: Reproducibility test\n    runs-on: ubuntu-24.04\n\n    steps:\n      - name: Setup\n        run: |\n          sudo apt update\n          sudo apt install --no-install-recommends -y libssl-dev pkg-config disorderfs faketime locales-all reprotest diffoscope\n          test -c /dev/fuse || mknod -m 666 /dev/fuse c 10 229\n          test -f /etc/mtab || ln -s ../proc/self/mounts /etc/mtab\n          curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sudo sh -s -- -y\n          echo \"/root/.cargo/bin\" >> $GITHUB_PATH\n\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          submodules: recursive\n\n      - name: Install tools\n        run: |\n          sudo env \"PATH=$PATH\" cargo install --locked just\n\n      - name: Reprotest\n        run: |\n          sudo ulimit -n 999999\n          sudo env \"PATH=$PATH\" reprotest -vv --min-cpus=99999 --vary=environment,build_path,kernel,aslr,num_cpus,-time,-user_group,fileordering,domain_host,home,locales,exec_path,timezone,umask --build-command 'just npm ci; just npm run build; SOURCE_DATE_EPOCH=0 cargo build --all-features --release' . target/release/warpgate\n"
  },
  {
    "path": ".github/workflows/scorecard.yml",
    "content": "# This workflow uses actions that are not certified by GitHub. They are provided\n# by a third-party and are governed by separate terms of service, privacy\n# policy, and support documentation.\n\nname: Scorecard supply-chain security\non:\n  # For Branch-Protection check. Only the default branch is supported. See\n  # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection\n  branch_protection_rule:\n  # To guarantee Maintained check is occasionally updated. See\n  # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained\n  schedule:\n    - cron: '42 9 * * 6'\n  push:\n    branches: [ \"main\" ]\n\n# Declare default permissions as read only.\npermissions: read-all\n\njobs:\n  analysis:\n    name: Scorecard analysis\n    runs-on: ubuntu-latest\n    # `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.\n    if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'\n    permissions:\n      # Needed to upload the results to code-scanning dashboard.\n      security-events: write\n      # Needed to publish results and get a badge (see publish_results below).\n      id-token: write\n      # Uncomment the permissions below if installing in a private repository.\n      # contents: read\n      # actions: read\n\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          persist-credentials: false\n\n      - name: \"Run analysis\"\n        uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3\n        with:\n          results_file: results.sarif\n          results_format: sarif\n          # (Optional) \"write\" PAT token. Uncomment the `repo_token` line below if:\n          # - you want to enable the Branch-Protection check on a *public* repository, or\n          # - you are installing Scorecard on a *private* repository\n          # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.\n          # repo_token: ${{ secrets.SCORECARD_TOKEN }}\n\n          # Public repositories:\n          #   - Publish results to OpenSSF REST API for easy access by consumers\n          #   - Allows the repository to include the Scorecard badge.\n          #   - See https://github.com/ossf/scorecard-action#publishing-results.\n          # For private repositories:\n          #   - `publish_results` will always be set to `false`, regardless\n          #     of the value entered here.\n          publish_results: true\n\n          # (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore\n          # file_mode: git\n\n      # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF\n      # format to the repository Actions tab.\n      - name: \"Upload artifact\"\n        uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1\n        with:\n          name: SARIF file\n          path: results.sarif\n          retention-days: 5\n\n      # Upload the results to GitHub's code scanning dashboard (optional).\n      # Commenting out will disable upload of results to your repo's Code Scanning dashboard\n      - name: \"Upload to code-scanning\"\n        uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0\n        with:\n          sarif_file: results.sarif\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non: [push, pull_request]\n\npermissions:\n  contents: read\n\njobs:\n  Tests:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd\n        with:\n          submodules: recursive\n\n      - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f\n        with:\n          node-version: 24\n\n      - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4\n        with:\n          key: \"test\"\n\n      - name: Install build deps\n        run: |\n          sudo apt-get install openssh-client expect\n          cargo install --locked just\n          # Pin cargo-llvm-cov to 0.6.15 because versions 0.6.16+ depend on ruzstd 0.8.x which uses\n          # the unstable `unsigned_is_multiple_of` feature not available in nightly-2025-01-01\n          cargo install --locked cargo-llvm-cov@0.6.15\n          cargo clean\n          rustup component add llvm-tools-preview\n\n      - name: Build UI\n        run: |\n          just npm ci\n          just openapi\n          just npm run openapi:tests-sdk\n          just npm run build\n\n      - name: Build images\n        working-directory: tests\n        run: |\n          make all\n\n      - name: Install deps\n        working-directory: tests\n        run: |\n          sudo apt update\n          sudo apt install -y gnome-keyring kubectl\n          pip3 install keyring==24 poetry==1.8.3\n          poetry install\n\n      - name: Run\n        working-directory: tests\n        run: |\n          TIMEOUT=120 poetry run ./run.sh\n          cargo llvm-cov report --lcov > coverage.lcov\n\n      - name: Upload coverage\n        uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50\n        with:\n          name: coverage.lcov\n          path: tests/coverage.lcov\n\n      - name: Upload coverage (HTML)\n        uses: actions/upload-artifact@2848b2cda0e5190984587ec6bb1f36730ca78d50\n        with:\n          name: coverage-html\n          path: target/llvm-cov/html\n\n      - name: SonarCloud Scan\n        uses: SonarSource/sonarqube-scan-action@a31c9398be7ace6bbfaf30c0bd5d415f843d45e9\n        if: ${{ env.SONAR_TOKEN }}\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}  # Needed to get PR information, if any\n          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\ndebug/\ntarget/\n\n# These are backup files generated by rustfmt\n**/*.rs.bk\n\n# MSVC Windows builds of rustc generate these, which store debugging information\n*.pdb\n\ntemp\nhost_key*\n.vscode\n\n# ---\n\ndata\ndata-*\nconfig.*.yaml\nconfig.yaml\n__pycache__\n.pytest_cache\ndhat-heap.json\n\n# IntelliJ based IDEs\n.idea/\n/.data/\n\n\ncdx.xml\n*.cdx.xml\n"
  },
  {
    "path": ".well-known/funding-manifest-urls",
    "content": "https://null.page/funding.json\n"
  },
  {
    "path": "Cargo.toml",
    "content": "# cargo-features = [\"profile-rustflags\"]\n\n[workspace]\nmembers = [\n    \"warpgate\",\n    \"warpgate-admin\",\n    \"warpgate-common\",\n    \"warpgate-common-http\",\n    \"warpgate-tls\",\n    \"warpgate-core\",\n    \"warpgate-db-migrations\",\n    \"warpgate-db-entities\",\n    \"warpgate-database-protocols\",\n    \"warpgate-ldap\",\n    \"warpgate-protocol-http\",\n    \"warpgate-protocol-kubernetes\",\n    \"warpgate-protocol-mysql\",\n    \"warpgate-protocol-postgres\",\n    \"warpgate-protocol-ssh\",\n    \"warpgate-sso\",\n    \"warpgate-web\",\n]\ndefault-members = [\"warpgate\"]\nresolver = \"2\"\n\n[workspace.dependencies]\nanyhow = { version = \"1.0\", default-features = false, features = [\"std\"] }\nbytes = { version = \"1.4\", default-features = false }\ndata-encoding = { version = \"2.3\", default-features = false, features = [\"alloc\", \"std\"] }\nserde = { version = \"1.0\", features = [\"derive\"], default-features = false }\nserde_json = { version = \"1.0\", default-features = false }\nrussh = { version = \"0.58.0\", features = [\"des\", \"rsa\", \"aws-lc-rs\"], default-features = false }\nfutures = { version = \"0.3\", default-features = false }\ntokio-stream = { version = \"0.1.17\", features = [\"net\"], default-features = false }\ntokio-rustls = { version = \"0.26\", default-features = false }\nenum_dispatch = { version = \"0.3.13\", default-features = false }\nrustls = { version = \"0.23\", default-features = false, features = [\"tls12\"] }\nsqlx = { version = \"0.8\", features = [\"tls-rustls-aws-lc-rs\"], default-features = false }\nsea-orm = { version = \"1.0\", default-features = false, features = [\"runtime-tokio\", \"macros\"] }\nsea-orm-migration = { version = \"1.0\", default-features = false, features = [\n    \"cli\",\n] }\npoem = { version = \"3.1\", features = [\n    \"cookie\",\n    \"session\",\n    \"anyhow\",\n    \"websocket\",\n    \"rustls\",\n    \"embed\",\n    \"server\",\n], default-features = false }\nhex = { version = \"0.4\", default-features = false }\npoem-openapi = { version = \"5.1\", features = [\n    \"stoplight-elements\",\n    \"chrono\",\n    \"uuid\",\n    \"static-files\",\n    \"cookie\",\n], default-features = false }\npassword-hash = { version = \"0.5\", features = [\"std\"], default-features = false }\ndelegate = { version = \"0.13\", default-features = false }\ntracing = { version = \"0.1\", default-features = false }\nschemars = { version = \"0.9.0\", default-features = false, features = [\"derive\", \"std\"] }\nldap3 = { version = \"0.12\", default-features = false, features = [\"tls-rustls-aws-lc-rs\"] }\nrustls-pki-types = { version = \"1.13\", default-features = false, features = [\"alloc\", \"std\"] }\nthiserror = { version = \"2\", default-features = false }\nrand = { version = \"0.8\", default-features = false }\nrand_chacha = { version = \"0.3\", default-features = false }\nrand_core = { version = \"0.6\", features = [\"std\"], default-features = false }\ndialoguer = { version = \"0.11\", default-features = false, features = [\"editor\", \"password\"] }\ntokio = { version = \"1.20\", features = [\"tracing\", \"signal\", \"macros\", \"rt-multi-thread\", \"io-util\"], default-features = false }\ngovernor = { version = \"0.10.0\", default-features = false, features = [\"std\", \"quanta\", \"jitter\"] }\nrcgen = { version = \"0.13\", features = [\"zeroize\", \"crypto\", \"aws_lc_rs\", \"pem\", \"x509-parser\"], default-features = false }\nx509-parser = \"0.17.0\"\nuuid = { version = \"1.3\", features = [\"v4\", \"serde\"], default-features = false }\nreqwest = { version = \"0.13\", features = [\n    \"http2\",                               # required for connecting to targets behind AWS ELB\n    \"rustls-no-provider\",\n    \"stream\",\n    \"gzip\",\n], default-features = false }\nreqwest_12 = { package = \"reqwest\", version = \"0.12\", features = [\n    \"http2\",\n    \"rustls-tls-native-roots-no-provider\",\n    \"stream\",\n    \"gzip\",\n], default-features = false } # separate copy to control features on openidconnect->oauth2->reqwest\nregex = { version = \"1.6\", default-features = false, features = [\"std\"] }\ntokio-tungstenite = { version = \"0.27\", features = [\"rustls-tls-native-roots\", \"connect\"], default-features = false }\nreqwest-websocket = \"0.6.0\"\n\n[profile.release]\nlto = true\npanic = \"abort\"\nstrip = \"debuginfo\"\n\n[profile.coverage]\ninherits = \"dev\"\n# rustflags = [\"-Cinstrument-coverage\"]\n"
  },
  {
    "path": "Cranky.toml",
    "content": "deny = [\n  \"unsafe_code\",\n  \"clippy::unwrap_used\",\n  \"clippy::expect_used\",\n  \"clippy::panic\",\n  \"clippy::indexing_slicing\",\n  \"clippy::dbg_macro\",\n]\n\nallow = [\n  \"clippy::result_large_err\",\n]\n"
  },
  {
    "path": "Cross.toml",
    "content": "[target.x86_64-unknown-linux-gnu]\npre-build = [\"apt-get update && apt-get install --assume-yes libz-dev\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2022 Warpgate contributors\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n<img src=\"https://github.com/user-attachments/assets/89be835b-ff96-46df-94c7-ae2d176615e3\" />\n</p>\n\n<br/>\n\n<p align=\"center\">\n<picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\".github/readme/brand-dark.svg\">\n  <source media=\"(prefers-color-scheme: light)\" srcset=\"warpgate-web/public/assets/brand.svg\">\n  <img alt=\"Shows a black logo in light color mode and a white one in dark color mode.\" src=\".github/readme/brand-dark.svg\">\n</picture>\n</p>\n\n<br/>\n<p align=\"center\">\n<a href=\"https://github.com/warp-tech/warpgate/releases/latest\"><img alt=\"GitHub All Releases\" src=\"https://img.shields.io/github/downloads/warp-tech/warpgate/total.svg?label=DOWNLOADS&logo=github&style=for-the-badge&color=8f8\"></a> &nbsp; <a href=\"https://nightly.link/warp-tech/warpgate/workflows/build/main\"><img src=\"https://shields.io/badge/-Nightly%20Builds-fa5?logo=hackthebox&logoColor=444&style=for-the-badge\"/></a> &nbsp; <a href=\"https://discord.gg/Vn7BjmzhtF\"><img alt=\"Discord\" src=\"https://img.shields.io/discord/1280890060195233934?style=for-the-badge&color=acc&logo=discord&logoColor=white&label=Discord\"></a>\n</p>\n\n\n<p align=\"center\">\n  <a href=\"https://ko-fi.com/J3J8KWTF\">\n    <img src=\"https://cdn.ko-fi.com/cdn/kofi3.png?v=2\" width=\"150\">\n  </a>\n</p>\n\n---\n\nWarpgate is a smart & fully transparent SSH, HTTPS, Kubernetes, MySQL, PostgreSQL bastion host that doesn't require a client app or an SSH wrapper.\n\n* Set it up in your DMZ, add user accounts and easily assign them to specific hosts and URLs within the network.\n* Warpgate will record every session for you to view (live) and replay later through a built-in admin web UI.\n* Not a jump host - forwards connection straight to the target in a way that's fully transparent to the client.\n* Native 2FA and SSO support (TOTP & OpenID Connect)\n* Single binary with no dependencies.\n* Written in 100% safe Rust.\n\n<p align=\"center\" style=\"margin: 30px 0 10px\">Supported by: </p>\n<p align=\"center\" style=\"margin: 0 0 30px\">\n<a href=\"https://floss.fund\"><img src=\"https://floss.fund/static/badge.svg\" alt=\"FLOSS/fund badge\" /></a>\n</p>\n\n\n## Getting started & downloads\n\n* See the [Getting started](https://warpgate.null.page/getting-started/) docs page (or [Getting started on Docker](https://warpgate.null.page/getting-started-on-docker/)).\n* [Release / beta binaries](https://github.com/warp-tech/warpgate/releases)\n* [Nightly builds](https://nightly.link/warp-tech/warpgate/workflows/build/main)\n\n## How is Warpgate different from a jump host / VPN / Teleport?\n\n| Warpgate | SSH jump host | VPN | Teleport |\n|-|-|-|-|\n| ✅ **Precise 1:1 assignment between users and services** | (Usually) full access to the network behind the jump host | (Usually) full access to the network | ✅ **Precise 1:1 assignment between users and services** |\n| ✅ **No custom client needed** | Jump host config needed | ✅ **No custom client needed** | Custom client required |\n| ✅ **2FA out of the box** | 🟡 2FA possible with additional PAM plugins | 🟡 Depends on the provider | ✅ **2FA out of the box** |\n| ✅ **SSO out of the box** | 🟡 SSO possible with additional PAM plugins | 🟡 Depends on the provider | Paid |\n| ✅ **Command-level audit** | 🟡 Connection-level audit on the jump host, no secure audit on the target if root access is given | No secure audit on the target if root access is given | ✅ **Command-level audit** |\n| ✅ **Full session recording** | No secure recording possible on the target if root access is given | No secure recording possible on the target if root access is given | ✅ **Full session recording** |\n| ✅ **Non-interactive connections** | 🟡 Non-interactive connections are possible if the clients supports jump hosts natively | ✅ **Non-interactive connections** | Non-interactive connections require using an SSH client wrapper or running a tunnel |\n| ✅ **Self-hosted, you own the data** | ✅ **Self-hosted, you own the data** | 🟡 Depends on the provider | SaaS |\n\n<center>\n      <img width=\"783\" alt=\"image\" src=\"https://user-images.githubusercontent.com/161476/162640762-a91a2816-48c0-44d9-8b03-5b1e2cb42d51.png\">\n</center>\n\n<table>\n  <tr>\n  <td>\n    <img src=\"https://github.com/user-attachments/assets/c9a6a372-198e-4f46-ab86-8c420dc24bca\">\n  </td>\n  <td>\n    <img src=\"https://github.com/user-attachments/assets/a2166426-e865-4aba-9600-520954bcfe7f\">\n  </td>\n  <td>\n    <img src=\"https://github.com/user-attachments/assets/366a5afb-aa86-4902-9080-eb2f40bf162c\">\n  </td>\n  </tr>\n</table>\n\n## Reporting security issues\n\nPlease use GitHub's [vulnerability reporting system](https://github.com/warp-tech/warpgate/security/policy).\n\n## Project Status\n\nThe project is ready for production.\n\n## How it works\n\nWarpgate is a service that you deploy on the bastion/DMZ host, which will accept SSH, HTTPS, Kubernetes, MySQL and PostgreSQL connections and provide an (optional) web admin UI.\n\nRun `warpgate setup` to interactively generate a config file, including port bindings. See [Getting started](https://warpgate.null.page/getting-started/) for details.\n\nIt receives connections with specifically formatted credentials, authenticates the user locally, connects to the target itself, and then connects both parties together while (optionally) recording the session.\n\nWhen connecting through HTTPS, Warpgate presents a selection of available targets, and will then proxy all traffic in a session to the selected target. You can switch between targets at any time.\n\nYou manage the target and user lists and assign them to each other through the admin UI, and the session history is stored in an SQLite database (default: in `/var/lib/warpgate`).\n\nYou can also use the admin web interface to view the live session list, review session recordings, logs and more.\n\n## Contributing / building from source\n\n* You'll need Rust, NodeJS and NPM\n* Clone the repo\n* [Just](https://github.com/casey/just) is used to run tasks - install it: `cargo install just`\n* Install the admin UI deps: `just npm install`\n* Build the frontend: `just npm run build`\n* Build Warpgate: `cargo build` (optionally `--release`)\n\nThe binary is in `target/{debug|release}`.\n\n### Tech stack\n\n* Rust 🦀\n  * HTTP: `poem-web`\n  * Database: SQLite via `sea-orm` + `sqlx`\n  * SSH: `russh`\n* Typescript\n  * Svelte\n  * Bootstrap\n\n### Backend API\n\n* Warpgate admin and user facing APIs use autogenerated OpenAPI schemas and SDKs. To update the SDKs after changing the query/response structures, run `just openapi-all`.\n\n## Contributors ✨\n\nThanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):\n\n<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->\n<!-- prettier-ignore-start -->\n<!-- markdownlint-disable -->\n<table>\n  <tbody>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Eugeny\"><img src=\"https://avatars.githubusercontent.com/u/161476?v=4?s=100\" width=\"100px;\" alt=\"Eugeny\"/><br /><sub><b>Eugeny</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=Eugeny\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://the-empire.systems/\"><img src=\"https://avatars.githubusercontent.com/u/18178614?v=4?s=100\" width=\"100px;\" alt=\"Spencer Heywood\"/><br /><sub><b>Spencer Heywood</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=heywoodlh\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/apiening\"><img src=\"https://avatars.githubusercontent.com/u/2064875?v=4?s=100\" width=\"100px;\" alt=\"Andreas Piening\"/><br /><sub><b>Andreas Piening</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=apiening\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Gurkengewuerz\"><img src=\"https://avatars.githubusercontent.com/u/10966337?v=4?s=100\" width=\"100px;\" alt=\"Niklas\"/><br /><sub><b>Niklas</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=Gurkengewuerz\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/notnooblord\"><img src=\"https://avatars.githubusercontent.com/u/11678665?v=4?s=100\" width=\"100px;\" alt=\"Nooblord\"/><br /><sub><b>Nooblord</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=notnooblord\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://shea.nz/\"><img src=\"https://avatars.githubusercontent.com/u/51303984?v=4?s=100\" width=\"100px;\" alt=\"Shea Smith\"/><br /><sub><b>Shea Smith</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=SheaSmith\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/samtoxie\"><img src=\"https://avatars.githubusercontent.com/u/7732658?v=4?s=100\" width=\"100px;\" alt=\"samtoxie\"/><br /><sub><b>samtoxie</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=samtoxie\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://p.foundation/\"><img src=\"https://avatars.githubusercontent.com/u/80860929?v=4?s=100\" width=\"100px;\" alt=\"P Foundation\"/><br /><sub><b>P Foundation</b></sub></a><br /><a href=\"#financial-pfoundation\" title=\"Financial\">💵</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://sixteenink.com\"><img src=\"https://avatars.githubusercontent.com/u/1480236?v=4?s=100\" width=\"100px;\" alt=\"Skyler Lewis\"/><br /><sub><b>Skyler Lewis</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=alairock\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.mohammednoureldin.com\"><img src=\"https://avatars.githubusercontent.com/u/14913147?v=4?s=100\" width=\"100px;\" alt=\"Mohammed Noureldin\"/><br /><sub><b>Mohammed Noureldin</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=MohammedNoureldin\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/mrmm\"><img src=\"https://avatars.githubusercontent.com/u/796467?v=4?s=100\" width=\"100px;\" alt=\"Mourad Maatoug\"/><br /><sub><b>Mourad Maatoug</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=mrmm\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://justinforlenza.dev\"><img src=\"https://avatars.githubusercontent.com/u/11709872?v=4?s=100\" width=\"100px;\" alt=\"Justin\"/><br /><sub><b>Justin</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=justinforlenza\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/liebermantodd\"><img src=\"https://avatars.githubusercontent.com/u/12155811?v=4?s=100\" width=\"100px;\" alt=\"liebermantodd\"/><br /><sub><b>liebermantodd</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=liebermantodd\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://blog.trieoflogs.com\"><img src=\"https://avatars.githubusercontent.com/u/14965074?v=4?s=100\" width=\"100px;\" alt=\"Hariharan\"/><br /><sub><b>Hariharan</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=cvhariharan\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/solidassassin\"><img src=\"https://avatars.githubusercontent.com/u/47082246?v=4?s=100\" width=\"100px;\" alt=\"Rokas Krivaitis\"/><br /><sub><b>Rokas Krivaitis</b></sub></a><br /><a href=\"https://github.com/warp-tech/warpgate/commits?author=solidassassin\" title=\"Code\">💻</a></td>\n    </tr>\n  </tbody>\n</table>\n\n<!-- markdownlint-restore -->\n<!-- prettier-ignore-end -->\n\n<!-- ALL-CONTRIBUTORS-LIST:END -->\n\nThis project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\nPlease report vunerabilities using GitHub's Private Vulnerability Reporting tool.\n\nYou can expect a response within a few days.\n\n---\n\nWarpgate considers the following trusted inputs:\n\n* Contents of the connected database\n* Contents of the config file, as long as Warpgate does not fail to lock down its permissions.\n* HTTP requests made by a session previously authenticated by a user who has the `warpgate:admin` role.\n* Network infrastructure and actuality and stability of target IPs/hostnames.\n\nIn particular, this does not include the traffic from known Warpgate targets.\n\n---\n\nCNA: [GitHub](https://www.cve.org/PartnerInformation/ListofPartners/partner/GitHub_M)\n"
  },
  {
    "path": "clippy.toml",
    "content": "avoid-breaking-exported-api = false\nallow-unwrap-in-tests = true\n"
  },
  {
    "path": "config-schema.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"title\": \"WarpgateConfigStore\",\n  \"type\": \"object\",\n  \"properties\": {\n    \"database_url\": {\n      \"type\": \"string\",\n      \"default\": \"sqlite:data/db\"\n    },\n    \"external_host\": {\n      \"type\": [\n        \"string\",\n        \"null\"\n      ],\n      \"default\": null\n    },\n    \"http\": {\n      \"$ref\": \"#/$defs/HttpConfig\",\n      \"default\": {\n        \"certificate\": \"\",\n        \"cookie_max_age\": \"1day\",\n        \"external_port\": null,\n        \"key\": \"\",\n        \"listen\": \"[::]:8888\",\n        \"session_max_age\": \"30m\",\n        \"sni_certificates\": [],\n        \"trust_x_forwarded_headers\": false\n      }\n    },\n    \"kubernetes\": {\n      \"$ref\": \"#/$defs/KubernetesConfig\",\n      \"default\": {\n        \"certificate\": \"\",\n        \"enable\": false,\n        \"external_port\": null,\n        \"key\": \"\",\n        \"listen\": \"[::]:8443\",\n        \"session_max_age\": \"30m\"\n      }\n    },\n    \"log\": {\n      \"$ref\": \"#/$defs/LogConfig\",\n      \"default\": {\n        \"format\": \"text\",\n        \"retention\": \"7days\",\n        \"send_to\": null\n      }\n    },\n    \"mysql\": {\n      \"$ref\": \"#/$defs/MySqlConfig\",\n      \"default\": {\n        \"certificate\": \"\",\n        \"enable\": false,\n        \"external_port\": null,\n        \"key\": \"\",\n        \"listen\": \"[::]:33306\"\n      }\n    },\n    \"postgres\": {\n      \"$ref\": \"#/$defs/PostgresConfig\",\n      \"default\": {\n        \"certificate\": \"\",\n        \"enable\": false,\n        \"external_port\": null,\n        \"key\": \"\",\n        \"listen\": \"[::]:55432\"\n      }\n    },\n    \"recordings\": {\n      \"$ref\": \"#/$defs/RecordingsConfig\",\n      \"default\": {\n        \"enable\": false,\n        \"path\": \"./data/recordings\"\n      }\n    },\n    \"ssh\": {\n      \"$ref\": \"#/$defs/SshConfig\",\n      \"default\": {\n        \"enable\": false,\n        \"external_port\": null,\n        \"host_key_verification\": \"prompt\",\n        \"inactivity_timeout\": \"5m\",\n        \"keepalive_interval\": null,\n        \"keys\": \"./data/keys\",\n        \"listen\": \"[::]:2222\"\n      }\n    },\n    \"sso_providers\": {\n      \"type\": \"array\",\n      \"default\": [],\n      \"items\": {\n        \"$ref\": \"#/$defs/SsoProviderConfig\"\n      }\n    }\n  },\n  \"$defs\": {\n    \"Duration\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"nanos\": {\n          \"type\": \"integer\",\n          \"format\": \"uint32\",\n          \"minimum\": 0\n        },\n        \"secs\": {\n          \"type\": \"integer\",\n          \"format\": \"uint64\",\n          \"minimum\": 0\n        }\n      },\n      \"required\": [\n        \"secs\",\n        \"nanos\"\n      ]\n    },\n    \"HttpConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"certificate\": {\n          \"type\": \"string\",\n          \"default\": \"\"\n        },\n        \"cookie_max_age\": {\n          \"type\": \"string\",\n          \"default\": \"1day\"\n        },\n        \"external_port\": {\n          \"type\": [\n            \"integer\",\n            \"null\"\n          ],\n          \"format\": \"uint16\",\n          \"default\": null,\n          \"maximum\": 65535,\n          \"minimum\": 0\n        },\n        \"key\": {\n          \"type\": \"string\",\n          \"default\": \"\"\n        },\n        \"listen\": {\n          \"$ref\": \"#/$defs/ListenEndpoint\",\n          \"default\": \"[::]:8888\"\n        },\n        \"session_max_age\": {\n          \"type\": \"string\",\n          \"default\": \"30m\"\n        },\n        \"sni_certificates\": {\n          \"type\": \"array\",\n          \"default\": [],\n          \"items\": {\n            \"$ref\": \"#/$defs/SniCertificateConfig\"\n          }\n        },\n        \"trust_x_forwarded_headers\": {\n          \"type\": \"boolean\",\n          \"default\": false\n        }\n      }\n    },\n    \"KubernetesConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"certificate\": {\n          \"type\": \"string\",\n          \"default\": \"\"\n        },\n        \"enable\": {\n          \"type\": \"boolean\",\n          \"default\": false\n        },\n        \"external_port\": {\n          \"type\": [\n            \"integer\",\n            \"null\"\n          ],\n          \"format\": \"uint16\",\n          \"default\": null,\n          \"maximum\": 65535,\n          \"minimum\": 0\n        },\n        \"key\": {\n          \"type\": \"string\",\n          \"default\": \"\"\n        },\n        \"listen\": {\n          \"$ref\": \"#/$defs/ListenEndpoint\",\n          \"default\": \"[::]:8443\"\n        },\n        \"session_max_age\": {\n          \"type\": \"string\",\n          \"default\": \"30m\"\n        }\n      }\n    },\n    \"ListenEndpoint\": {\n      \"type\": \"string\"\n    },\n    \"LogConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"format\": {\n          \"$ref\": \"#/$defs/LogFormat\",\n          \"default\": \"text\"\n        },\n        \"retention\": {\n          \"type\": \"string\",\n          \"default\": \"7days\"\n        },\n        \"send_to\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ],\n          \"default\": null\n        }\n      }\n    },\n    \"LogFormat\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"text\",\n        \"json\"\n      ]\n    },\n    \"MySqlConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"certificate\": {\n          \"type\": \"string\",\n          \"default\": \"\"\n        },\n        \"enable\": {\n          \"type\": \"boolean\",\n          \"default\": false\n        },\n        \"external_port\": {\n          \"type\": [\n            \"integer\",\n            \"null\"\n          ],\n          \"format\": \"uint16\",\n          \"default\": null,\n          \"maximum\": 65535,\n          \"minimum\": 0\n        },\n        \"key\": {\n          \"type\": \"string\",\n          \"default\": \"\"\n        },\n        \"listen\": {\n          \"$ref\": \"#/$defs/ListenEndpoint\",\n          \"default\": \"[::]:33306\"\n        }\n      }\n    },\n    \"PostgresConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"certificate\": {\n          \"type\": \"string\",\n          \"default\": \"\"\n        },\n        \"enable\": {\n          \"type\": \"boolean\",\n          \"default\": false\n        },\n        \"external_port\": {\n          \"type\": [\n            \"integer\",\n            \"null\"\n          ],\n          \"format\": \"uint16\",\n          \"default\": null,\n          \"maximum\": 65535,\n          \"minimum\": 0\n        },\n        \"key\": {\n          \"type\": \"string\",\n          \"default\": \"\"\n        },\n        \"listen\": {\n          \"$ref\": \"#/$defs/ListenEndpoint\",\n          \"default\": \"[::]:55432\"\n        }\n      }\n    },\n    \"RecordingsConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"enable\": {\n          \"type\": \"boolean\",\n          \"default\": false\n        },\n        \"path\": {\n          \"type\": \"string\",\n          \"default\": \"./data/recordings\"\n        }\n      }\n    },\n    \"RoleMapping\": {\n      \"description\": \"A role mapping value that accepts either a single role or a list of roles.\\n In YAML config: `\\\"group\\\": \\\"role\\\"` or `\\\"group\\\": [\\\"role1\\\", \\\"role2\\\"]`\",\n      \"anyOf\": [\n        {\n          \"type\": \"string\"\n        },\n        {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      ]\n    },\n    \"SniCertificateConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"certificate\": {\n          \"type\": \"string\"\n        },\n        \"key\": {\n          \"type\": \"string\"\n        }\n      },\n      \"required\": [\n        \"certificate\",\n        \"key\"\n      ]\n    },\n    \"SshConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"enable\": {\n          \"type\": \"boolean\",\n          \"default\": false\n        },\n        \"external_port\": {\n          \"type\": [\n            \"integer\",\n            \"null\"\n          ],\n          \"format\": \"uint16\",\n          \"default\": null,\n          \"maximum\": 65535,\n          \"minimum\": 0\n        },\n        \"host_key_verification\": {\n          \"$ref\": \"#/$defs/SshHostKeyVerificationMode\",\n          \"default\": \"prompt\"\n        },\n        \"inactivity_timeout\": {\n          \"type\": \"string\",\n          \"default\": \"5m\"\n        },\n        \"keepalive_interval\": {\n          \"anyOf\": [\n            {\n              \"$ref\": \"#/$defs/Duration\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ],\n          \"default\": null\n        },\n        \"keys\": {\n          \"type\": \"string\",\n          \"default\": \"./data/keys\"\n        },\n        \"listen\": {\n          \"$ref\": \"#/$defs/ListenEndpoint\",\n          \"default\": \"[::]:2222\"\n        }\n      }\n    },\n    \"SshHostKeyVerificationMode\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"prompt\",\n        \"auto_accept\",\n        \"auto_reject\"\n      ]\n    },\n    \"SsoInternalProviderConfig\": {\n      \"oneOf\": [\n        {\n          \"type\": \"object\",\n          \"properties\": {\n            \"type\": {\n              \"type\": \"string\",\n              \"const\": \"google\"\n            },\n            \"admin_email\": {\n              \"description\": \"A Google Workspace admin email for domain-wide delegation\",\n              \"type\": [\n                \"string\",\n                \"null\"\n              ]\n            },\n            \"admin_role_mappings\": {\n              \"type\": [\n                \"object\",\n                \"null\"\n              ],\n              \"additionalProperties\": {\n                \"$ref\": \"#/$defs/RoleMapping\"\n              }\n            },\n            \"client_id\": {\n              \"type\": \"string\"\n            },\n            \"client_secret\": {\n              \"type\": \"string\"\n            },\n            \"role_mappings\": {\n              \"description\": \"Maps Google group email addresses to Warpgate role names.\\n Use \\\"*\\\" as a key to set a default role for any group not explicitly mapped.\",\n              \"type\": [\n                \"object\",\n                \"null\"\n              ],\n              \"additionalProperties\": {\n                \"$ref\": \"#/$defs/RoleMapping\"\n              }\n            },\n            \"service_account_email\": {\n              \"description\": \"Service account email for Google Directory API group lookups\",\n              \"type\": [\n                \"string\",\n                \"null\"\n              ]\n            },\n            \"service_account_key\": {\n              \"description\": \"PEM private key from the service account JSON key file\",\n              \"type\": [\n                \"string\",\n                \"null\"\n              ]\n            }\n          },\n          \"required\": [\n            \"type\",\n            \"client_id\",\n            \"client_secret\"\n          ]\n        },\n        {\n          \"type\": \"object\",\n          \"properties\": {\n            \"type\": {\n              \"type\": \"string\",\n              \"const\": \"apple\"\n            },\n            \"client_id\": {\n              \"type\": \"string\"\n            },\n            \"client_secret\": {\n              \"type\": \"string\"\n            },\n            \"key_id\": {\n              \"type\": \"string\"\n            },\n            \"team_id\": {\n              \"type\": \"string\"\n            }\n          },\n          \"required\": [\n            \"type\",\n            \"client_id\",\n            \"client_secret\",\n            \"key_id\",\n            \"team_id\"\n          ]\n        },\n        {\n          \"type\": \"object\",\n          \"properties\": {\n            \"type\": {\n              \"type\": \"string\",\n              \"const\": \"azure\"\n            },\n            \"client_id\": {\n              \"type\": \"string\"\n            },\n            \"client_secret\": {\n              \"type\": \"string\"\n            },\n            \"tenant\": {\n              \"type\": \"string\"\n            }\n          },\n          \"required\": [\n            \"type\",\n            \"client_id\",\n            \"client_secret\",\n            \"tenant\"\n          ]\n        },\n        {\n          \"type\": \"object\",\n          \"properties\": {\n            \"type\": {\n              \"type\": \"string\",\n              \"const\": \"custom\"\n            },\n            \"additional_trusted_audiences\": {\n              \"type\": [\n                \"array\",\n                \"null\"\n              ],\n              \"items\": {\n                \"type\": \"string\"\n              }\n            },\n            \"admin_role_mappings\": {\n              \"type\": [\n                \"object\",\n                \"null\"\n              ],\n              \"additionalProperties\": {\n                \"$ref\": \"#/$defs/RoleMapping\"\n              }\n            },\n            \"client_id\": {\n              \"type\": \"string\"\n            },\n            \"client_secret\": {\n              \"type\": \"string\"\n            },\n            \"issuer_url\": {\n              \"type\": \"string\"\n            },\n            \"role_mappings\": {\n              \"type\": [\n                \"object\",\n                \"null\"\n              ],\n              \"additionalProperties\": {\n                \"$ref\": \"#/$defs/RoleMapping\"\n              }\n            },\n            \"scopes\": {\n              \"type\": \"array\",\n              \"items\": {\n                \"type\": \"string\"\n              }\n            },\n            \"trust_unknown_audiences\": {\n              \"type\": \"boolean\",\n              \"default\": false\n            }\n          },\n          \"required\": [\n            \"type\",\n            \"client_id\",\n            \"client_secret\",\n            \"issuer_url\",\n            \"scopes\"\n          ]\n        }\n      ]\n    },\n    \"SsoProviderConfig\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"auto_create_users\": {\n          \"type\": \"boolean\",\n          \"default\": false\n        },\n        \"default_credential_policy\": {\n          \"description\": \"Default credential policy for auto-created users.\\n Keys: \\\"http\\\", \\\"ssh\\\", \\\"mysql\\\", \\\"postgres\\\"\\n Values: list of credential kinds e.g. [\\\"sso\\\"], [\\\"web\\\"], []\"\n        },\n        \"label\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"name\": {\n          \"type\": \"string\"\n        },\n        \"provider\": {\n          \"$ref\": \"#/$defs/SsoInternalProviderConfig\"\n        },\n        \"return_domain_whitelist\": {\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"return_url_prefix\": {\n          \"$ref\": \"#/$defs/SsoProviderReturnUrlPrefix\",\n          \"default\": \"@\"\n        }\n      },\n      \"required\": [\n        \"name\",\n        \"provider\"\n      ]\n    },\n    \"SsoProviderReturnUrlPrefix\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"@\",\n        \"_\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "deny.toml",
    "content": "# This template contains all of the possible sections and their default values\n\n# Note that all fields that take a lint level have these possible values:\n# * deny - An error will be produced and the check will fail\n# * warn - A warning will be produced, but the check will not fail\n# * allow - No warning or error will be produced, though in some cases a note\n# will be\n\n# The values provided in this template are the default values that will be used\n# when any section or field is not specified in your own configuration\n\n# Root options\n\n# The graph table configures how the dependency graph is constructed and thus\n# which crates the checks are performed against\n[graph]\n# If 1 or more target triples (and optionally, target_features) are specified,\n# only the specified targets will be checked when running `cargo deny check`.\n# This means, if a particular package is only ever used as a target specific\n# dependency, such as, for example, the `nix` crate only being used via the\n# `target_family = \"unix\"` configuration, that only having windows targets in\n# this list would mean the nix crate, as well as any of its exclusive\n# dependencies not shared by any other crates, would be ignored, as the target\n# list here is effectively saying which targets you are building for.\ntargets = [\n    \"x86_64-unknown-linux-gnu\",\n    \"aarch64-unknown-linux-gnu\",\n    \"x86_64-apple-darwin\",\n    \"aarch64-apple-darwin\",\n]\n# When creating the dependency graph used as the source of truth when checks are\n# executed, this field can be used to prune crates from the graph, removing them\n# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate\n# is pruned from the graph, all of its dependencies will also be pruned unless\n# they are connected to another crate in the graph that hasn't been pruned,\n# so it should be used with care. The identifiers are [Package ID Specifications]\n# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)\n#exclude = []\n# If true, metadata will be collected with `--all-features`. Note that this can't\n# be toggled off if true, if you want to conditionally enable `--all-features` it\n# is recommended to pass `--all-features` on the cmd line instead\nall-features = false\n# If true, metadata will be collected with `--no-default-features`. The same\n# caveat with `all-features` applies\nno-default-features = false\n# If set, these feature will be enabled when collecting metadata. If `--features`\n# is specified on the cmd line they will take precedence over this option.\n#features = []\n\n# The output table provides options for how/if diagnostics are outputted\n[output]\n# When outputting inclusion graphs in diagnostics that include features, this\n# option can be used to specify the depth at which feature edges will be added.\n# This option is included since the graphs can be quite large and the addition\n# of features from the crate(s) to all of the graph roots can be far too verbose.\n# This option can be overridden via `--feature-depth` on the cmd line\nfeature-depth = 1\n\n# This section is considered when running `cargo deny check advisories`\n# More documentation for the advisories section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html\n[advisories]\n# The path where the advisory databases are cloned/fetched into\n#db-path = \"$CARGO_HOME/advisory-dbs\"\n# The url(s) of the advisory databases to use\n#db-urls = [\"https://github.com/rustsec/advisory-db\"]\n# A list of advisory IDs to ignore. Note that ignored advisories will still\n# output a note when they are encountered.\nignore = [\n    \"RUSTSEC-2023-0071\",\n    \"RUSTSEC-2021-0139\", # ansi-term is unmaintained\n    \"RUSTSEC-2025-0134\", # rustls-pemfile is deprecated but poem is still using it\n]\n# If this is true, then cargo deny will use the git executable to fetch advisory database.\n# If this is false, then it uses a built-in git library.\n# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.\n# See Git Authentication for more information about setting up git authentication.\n#git-fetch-with-cli = true\n\n\n# This section is considered when running `cargo deny check bans`.\n# More documentation about the 'bans' section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html\n[bans]\n# Lint level for when multiple versions of the same crate are detected\n# multiple-versions = \"warn\"\n# Lint level for when a crate version requirement is `*`\nwildcards = \"warn\"\n# The graph highlighting used when creating dotgraphs for crates\n# with multiple versions\n# * lowest-version - The path to the lowest versioned duplicate is highlighted\n# * simplest-path - The path to the version with the fewest edges is highlighted\n# * all - Both lowest-version and simplest-path are used\nhighlight = \"all\"\n# The default lint level for `default` features for crates that are members of\n# the workspace that is being checked. This can be overridden by allowing/denying\n# `default` on a crate-by-crate basis if desired.\nworkspace-default-features = \"warn\"\n# The default lint level for `default` features for external crates that are not\n# members of the workspace. This can be overridden by allowing/denying `default`\n# on a crate-by-crate basis if desired.\nexternal-default-features = \"allow\"\n# List of crates that are allowed. Use with care!\nallow = [\n    #\"ansi_term@0.11.0\",\n    #{ crate = \"ansi_term@0.11.0\", reason = \"you can specify a reason it is allowed\" },\n]\n# List of crates to deny\ndeny = [\n    \"openssl-sys\"\n    #\"ansi_term@0.11.0\",\n    #{ crate = \"ansi_term@0.11.0\", reason = \"you can specify a reason it is banned\" },\n    # Wrapper crates can optionally be specified to allow the crate when it\n    # is a direct dependency of the otherwise banned crate\n    #{ crate = \"ansi_term@0.11.0\", wrappers = [\"this-crate-directly-depends-on-ansi_term\"] },\n]\n\n# TODO reenable once poem updates its tokio-rustls dependency\n# [[bans.features]]\n# crate = \"rustls\"\n# # Features to not allow\n# deny = [\"ring\"]\n\n[[bans.features]]\ncrate = \"reqwest\"\n# Features to not allow\ndeny = [\"rustls-tls-webpki-roots\"]\n\n\n\n# Features to allow\n#allow = [\n#    \"rustls\",\n#    \"__rustls\",\n#    \"__tls\",\n#    \"hyper-rustls\",\n#    \"rustls\",\n#    \"rustls-pemfile\",\n#    \"rustls-tls-webpki-roots\",\n#    \"tokio-rustls\",\n#    \"webpki-roots\",\n#]\n# If true, the allowed features must exactly match the enabled feature set. If\n# this is set there is no point setting `deny`\n#exact = true\n\n# Certain crates/versions that will be skipped when doing duplicate detection.\n# skip = [\n    #\"ansi_term@0.11.0\",\n    #{ crate = \"ansi_term@0.11.0\", reason = \"you can specify a reason why it can't be updated/removed\" },\n# ]\n# Similarly to `skip` allows you to skip certain crates during duplicate\n# detection. Unlike skip, it also includes the entire tree of transitive\n# dependencies starting at the specified crate, up to a certain depth, which is\n# by default infinite.\n# skip-tree = [\n    #\"ansi_term@0.11.0\", # will be skipped along with _all_ of its direct and transitive dependencies\n    #{ crate = \"ansi_term@0.11.0\", depth = 20 },\n# ]\n\n# This section is considered when running `cargo deny check sources`.\n# More documentation about the 'sources' section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html\n[sources]\n# Lint level for what to happen when a crate from a crate registry that is not\n# in the allow list is encountered\nunknown-registry = \"warn\"\n# Lint level for what to happen when a crate from a git repository that is not\n# in the allow list is encountered\nunknown-git = \"warn\"\n# List of URLs for allowed crate registries. Defaults to the crates.io index\n# if not specified. If it is specified but empty, no registries are allowed.\nallow-registry = [\"https://github.com/rust-lang/crates.io-index\"]\n# List of URLs for allowed Git repositories\nallow-git = []\n\n[sources.allow-org]\n# github.com organizations to allow git sources for\ngithub = []\n# gitlab.com organizations to allow git sources for\ngitlab = []\n# bitbucket.org organizations to allow git sources for\nbitbucket = []\n\n[licenses]\nconfidence-threshold = 0.95\nallow = [\n    \"MIT\",\n    \"Apache-2.0\",\n    \"Unicode-3.0\",\n    \"ISC\",\n    \"OpenSSL\",\n    \"BSD-2-Clause\",\n    \"BSD-3-Clause\",\n    \"Zlib\",\n    \"WTFPL\",\n    \"CC0-1.0\",\n    \"LGPL-3.0\",\n    \"MPL-2.0\",\n    \"CDLA-Permissive-2.0\",\n]\n\n[[licenses.clarify]]\ncrate = \"ring\"\nexpression = \"OpenSSL\"\nlicense-files = [\n    { path = \"LICENSE\", hash = 0xbd0eed23 },\n]\n\n[[licenses.clarify]]\ncrate = \"webpki\"\nexpression = \"ISC\"\nlicense-files = [\n    { path = \"LICENSE\", hash = 0x001c7e6c },\n]\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "# syntax=docker/dockerfile:1.3-labs\n# hadolint global ignore=DL3008\nFROM rust:1.94.0-bullseye@sha256:16950191527a4cb9e0762d9d48b705a6315158e4035e64f7a93ce8656a1b053c AS build\n\nENV DEBIAN_FRONTEND=noninteractive\n\nSHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]\nRUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \\\n    && apt-get update \\\n    && apt-get install -y --no-install-recommends ca-certificates-java nodejs openjdk-17-jdk \\\n    && rm -rf /var/lib/apt/lists/* \\\n    && cargo install just\n\nCOPY . /opt/warpgate\n\n# Needed to correctly embed the version number and the dirty state flag\nCOPY .git/ /opt/warpgate/.git/\n\n# for rust-embed determinism\nENV SOURCE_DATE_EPOCH=0\n\nWORKDIR /opt/warpgate\n\nRUN just npm ci \\\n    && just openapi \\\n    && just npm run build \\\n    && cargo build --features mysql,postgres --release\n\nFROM debian:bullseye-20260316@sha256:943d97fa707482c24e1bc2bdd0b0adc45f75eb345c61dc4272c4157f9a2cc9cc\nLABEL maintainer=heywoodlh\nARG USER_ID=1000\n\nENV DEBIAN_FRONTEND=noninteractive\nRUN <<EOF\n  set -xe\n  apt-get -y update -qq\n  apt-get install --no-install-recommends --no-install-suggests -y \\\n    ca-certificates wget\n  apt-get clean\n  rm -rf /var/lib/apt/lists/*\nEOF\n\n# RUN adduser warpgate --system --home /data\nRUN adduser warpgate --disabled-password --gecos \"\" --uid ${USER_ID} --home /data --shell /usr/sbin/nologin\n\nCOPY --from=build /opt/warpgate/target/release/warpgate /usr/local/bin/warpgate\n\nVOLUME /data\n\nENV WARPGATE_CONFIG=/data/warpgate.yaml\n\nHEALTHCHECK CMD warpgate healthcheck\n\nENV DOCKER=1\nUSER warpgate\nENTRYPOINT [\"warpgate\"]\nCMD [\"run\"]\n"
  },
  {
    "path": "docker/docker-compose.yml",
    "content": "services:\n  warpgate:\n    image: ghcr.io/warp-tech/warpgate\n    ports:\n      - 2222:2222\n      - 8888:8888\n      - 33306:33306\n    volumes:\n      - ./data:/data\n    stdin_open: true\n    tty: true\n"
  },
  {
    "path": "helm/warpgate/.helmignore",
    "content": "# Patterns to ignore when building packages.\n# This supports shell glob matching, relative path matching, and\n# negation (prefixed with !). Only one pattern per line.\n.DS_Store\n# Common VCS dirs\n.git/\n.gitignore\n.bzr/\n.bzrignore\n.hg/\n.hgignore\n.svn/\n# Common backup files\n*.swp\n*.bak\n*.tmp\n*.orig\n*~\n# Various IDEs\n.project\n.idea/\n*.tmproj\n.vscode/\n"
  },
  {
    "path": "helm/warpgate/Chart.yaml",
    "content": "apiVersion: v2\nname: warpgate\ndescription: A Helm chart for WarpGate inside of Kubernetes\n\n# A chart can be either an 'application' or a 'library' chart.\n#\n# Application charts are a collection of templates that can be packaged into versioned archives\n# to be deployed.\n#\n# Library charts provide useful utilities or functions for the chart developer. They're included as\n# a dependency of application charts to inject those utilities and functions into the rendering\n# pipeline. Library charts do not define any templates and therefore cannot be deployed.\ntype: application\n\n# This is the chart version. This version number should be incremented each time you make changes\n# to the chart and its templates, including the app version.\n# Versions are expected to follow Semantic Versioning (https://semver.org/)\n# version: 0.1.3\nversion: 0.0.2\n\n# This is the version number of the application being deployed. This version number should be\n# incremented each time you make changes to the application. Versions are not expected to\n# follow Semantic Versioning. They should reflect the version the application is using.\n# It is recommended to use it with quotes.\nappVersion: \"0.16.0\"\n"
  },
  {
    "path": "helm/warpgate/templates/NOTES.txt",
    "content": ""
  },
  {
    "path": "helm/warpgate/templates/_helpers.tpl",
    "content": "{{/*\nExpand the name of the chart.\n*/}}\n{{- define \"warpgate.name\" -}}\n{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCreate a default fully qualified app name.\nWe truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).\nIf release name contains chart name it will be used as a full name.\n*/}}\n{{- define \"warpgate.fullname\" -}}\n{{- if .Values.fullnameOverride }}\n{{- .Values.fullnameOverride | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- $name := default .Chart.Name .Values.nameOverride }}\n{{- if contains $name .Release.Name }}\n{{- .Release.Name | trunc 63 | trimSuffix \"-\" }}\n{{- else }}\n{{- printf \"%s-%s\" .Release.Name $name | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n{{- end }}\n{{- end }}\n\n{{/*\nCreate chart name and version as used by the chart label.\n*/}}\n{{- define \"warpgate.chart\" -}}\n{{- printf \"%s-%s\" .Chart.Name .Chart.Version | replace \"+\" \"_\" | trunc 63 | trimSuffix \"-\" }}\n{{- end }}\n\n{{/*\nCommon labels\n*/}}\n{{- define \"warpgate.labels\" -}}\nhelm.sh/chart: {{ include \"warpgate.chart\" . }}\n{{ include \"warpgate.selectorLabels\" . }}\n{{- if .Chart.AppVersion }}\napp.kubernetes.io/version: {{ .Chart.AppVersion | quote }}\n{{- end }}\napp.kubernetes.io/managed-by: {{ .Release.Service }}\n{{- end }}\n\n{{/*\nSelector labels\n*/}}\n{{- define \"warpgate.selectorLabels\" -}}\napp.kubernetes.io/name: {{ include \"warpgate.name\" . }}\napp.kubernetes.io/instance: {{ .Release.Name }}\n{{- end }}\n\n{{/*\nCreate the name of the service account to use\n*/}}\n{{- define \"warpgate.serviceAccountName\" -}}\n{{- if .Values.serviceAccount.create }}\n{{- default (include \"warpgate.fullname\" .) .Values.serviceAccount.name }}\n{{- else }}\n{{- default \"default\" .Values.serviceAccount.name }}\n{{- end }}\n{{- end }}\n"
  },
  {
    "path": "helm/warpgate/templates/configmap.yaml",
    "content": "{{- if .Values.overrides_config }}\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: {{ include \"warpgate.fullname\" . }}-config-overrides\n  labels:\n    {{- include \"warpgate.labels\" . | nindent 4 }}\ndata:\n  warpgate.yaml: |\n  {{ .Values.overrides_config | nindent 4 }}\n{{- end }}\n"
  },
  {
    "path": "helm/warpgate/templates/deployment.yaml",
    "content": "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n  name: {{ include \"warpgate.fullname\" . }}\n  labels:\n    {{- include \"warpgate.labels\" . | nindent 4 }}\n    {{- with .Values.deploymentLabels }}\n      {{- toYaml . | nindent 4 }}\n    {{- end }}\n  {{- with .Values.deploymentAnnotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\nspec:\n  replicas: {{ .Values.replicaCount }}\n  selector:\n    matchLabels:\n      {{- include \"warpgate.selectorLabels\" . | nindent 6 }}\n  template:\n    metadata:\n      {{- with .Values.podAnnotations }}\n      annotations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      labels:\n        {{- include \"warpgate.labels\" . | nindent 8 }}\n        {{- with .Values.podLabels }}\n        {{- toYaml . | nindent 8 }}\n        {{- end }}\n    spec:\n      {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.podSecurityContext }}\n      securityContext:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      initContainers:\n        - name: {{ .Chart.Name }}-setup\n          {{- with .Values.securityContext }}\n          securityContext:\n            {{- toYaml . | nindent 12 }}\n          {{- end }}\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}\"\n          {{- if .Values.setup.envFromSecret }}\n          env:\n          {{- range $key, $val := .Values.setup.envFromSecret }}\n            - name: {{ $key }}\n              valueFrom:\n                secretKeyRef:\n                  name: {{ (split \"/\" $val)._0 }}\n                  key: {{ (split \"/\" $val)._1 }}\n          {{- end }}\n          {{- end }}\n          command:\n            - /bin/sh\n            - -c\n            - |\n              set -e\n\n              if [ -d /ssh-keys ]; then\n                mkdir -p /data/ssh-keys\n                cp /ssh-keys/client-ed25519 /ssh-keys/client-rsa /ssh-keys/host-ed25519 /ssh-keys/host-rsa /data/ssh-keys/\n                chmod -R 600 /data/ssh-keys/*\n              fi\n\n              if [ -d /tls-cert ]; then\n                cp /tls-cert/tls.crt /data/tls.certificate.pem\n                cp /tls-cert/tls.key /data/tls.key.pem\n                chmod 600 /data/tls.certificate.pem\n                chmod 600 /data/tls.key.pem\n              fi\n\n              {{- if .Values.setup.enabled }}\n              {{- if eq .Values.setup.type \"podinit\" }}\n              if [ ! -f /data/warpgate.yaml ]; then\n                warpgate -c /data/warpgate.yaml  unattended-setup --data-path /data\n                  {{- if .Values.setup.http }} --http-port {{ .Values.setup.http }} {{- end }}\n                  {{- if .Values.setup.ssh }} --ssh-port {{ .Values.setup.ssh }} {{- end }}\n                  {{- if .Values.setup.mysql }} --mysql-port {{ .Values.setup.mysql }} {{- end }}\n                  {{- if .Values.setup.pgsql }} --postgres-port {{ .Values.setup.pgsql }} {{- end }}\n                  {{- if .Values.setup.kubernetes }} --kubernetes-port {{ .Values.setup.kubernetes }} {{- end }}\n                  {{- if .Values.setup.databaseUrl }} --database-url \"{{ .Values.setup.databaseUrl }}\" {{- end }}\n                  {{- if .Values.setup.recordSessions }} --record-sessions {{- end }}\n\n                {{- if .Values.setup.disableIpV6 }}\n                sed -i 's/\\[::\\]:\\([0-9]\\+\\)/0.0.0.0:\\1/g' /data/warpgate.yaml\n                {{- end }}\n              fi\n              {{- end }}\n              {{- end }}\n\n              if [ -d /override ]; then\n                cp /override/warpgate.yaml /data/warpgate.yaml\n                {{- if .Values.config_env_var_replace }}\n                VARS=\"{{ .Values.config_env_var_replace }}\"\n                for VAR in $VARS; do\n                 VAL=$(printenv \"$VAR\")\n                 ESCAPED=$(printf '%s' \"$VAL\" | sed -e 's/[\\/&]/\\\\&/g')\n                 FVAR=$(printf '$%s' \"$VAR\")\n                 sed -i \"s/$FVAR/$ESCAPED/g\" /data/warpgate.yaml\n                done\n                {{- end }}\n                chmod 600 /data/warpgate.yaml\n              fi\n          volumeMounts:\n            - name: data\n              mountPath: /data\n            {{- if .Values.ssh_keys_secret  }}\n            - name: ssh-keys\n              mountPath: /ssh-keys\n            {{- end }}\n            {{- if .Values.tls_cert_secret }}\n            - name: tls-cert\n              mountPath: /tls-cert\n            {{- end }}\n            {{- if .Values.overrides_config }}\n            - name: override-config\n              mountPath: /override\n            {{- end }}\n          {{- if .Values.volume_mounts }}\n            {{- toYaml .Values.volume_mounts | nindent 12 }}\n          {{- end }}\n      containers:\n        - name: {{ .Chart.Name }}\n          {{- with .Values.securityContext }}\n          securityContext:\n            {{- toYaml . | nindent 12 }}\n          {{- end }}\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}\"\n          imagePullPolicy: {{ .Values.image.pullPolicy }}\n          command:\n            - warpgate\n            - -c\n            - /data/warpgate.yaml\n            - run\n          ports:\n            {{- if .Values.setup.ssh }}\n            - name: ssh\n              containerPort: {{ .Values.setup.ssh }}\n              protocol: TCP\n            {{- end }}\n            {{- if .Values.setup.http }}\n            - name: http\n              containerPort: {{ .Values.setup.http }}\n              protocol: TCP\n            {{- end }}\n            {{- if .Values.setup.mysql }}\n            - name: mysql\n              containerPort: {{ .Values.setup.mysql }}\n              protocol: TCP\n            {{- end }}\n            {{- if .Values.setup.pgsql }}\n            - name: pgsql\n              containerPort: {{ .Values.setup.pgsql }}\n              protocol: TCP\n            {{- end }}\n            {{- if .Values.setup.kubernetes }}\n            - name: kubernetes\n              containerPort: {{ .Values.setup.kubernetes }}\n              protocol: TCP\n            {{- end }}\n          {{- with .Values.resources }}\n          resources:\n            {{- toYaml . | nindent 12 }}\n          {{- end }}\n          volumeMounts:\n            {{- with .Values.volume_mounts }}\n              {{- toYaml . | nindent 12 }}\n            {{- end }}\n            - name: data\n              mountPath: /data\n      volumes:\n        {{- with .Values.volumes }}\n          {{- toYaml . | nindent 8 }}\n        {{- end }}\n        {{- with .Values.ssh_keys_secret }}\n        - name: ssh-keys\n          secret:\n            secretName: {{ . }}\n        {{- end }}\n        {{- with .Values.tls_cert_secret }}\n        - name: tls-cert\n          secret:\n            secretName: {{ . }}\n        {{- end }}\n        {{- if .Values.overrides_config }}\n        - name: override-config\n          configMap:\n            name: {{ include \"warpgate.fullname\" . }}-config-overrides\n        {{- end }}\n        {{- if .Values.data.pvc.enabled }}\n        - name: data\n          persistentVolumeClaim:\n            {{- if .Values.data.pvc.claimName }}\n            claimName: {{ .Values.data.pvc.claimName }}\n            {{- else }}\n            claimName: {{ include \"warpgate.fullname\" . }}-data\n            {{- end }}\n        {{- else }}\n        - name: data\n          emptyDir: {}\n        {{- end }}\n      {{- with .Values.nodeSelector }}\n      nodeSelector:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.affinity }}\n      affinity:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      {{- with .Values.tolerations }}\n      tolerations:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n"
  },
  {
    "path": "helm/warpgate/templates/httproute.yaml",
    "content": "{{- if .Values.httpRoute.enabled -}}\napiVersion: gateway.networking.k8s.io/v1\nkind: HTTPRoute\nmetadata:\n  name: {{ include \"warpgate.fullname\" . }}\n  namespace: {{ .Release.Namespace }}\n  labels:\n    {{- include \"warpgate.labels\" . | nindent 4 }}\n  {{- with .Values.httpRoute.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\nspec:\n  {{- with .Values.httpRoute.parentRefs }}\n  parentRefs:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\n  {{- with .Values.httpRoute.hostnames }}\n  hostnames:\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\n  rules:\n    - backendRefs:\n        - name: {{ include \"warpgate.fullname\" . }}\n          port: {{ .Values.service.ports.http }}\n{{- end }}\n"
  },
  {
    "path": "helm/warpgate/templates/ingress.yaml",
    "content": "{{- if .Values.ingress.enabled }}\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: {{ include \"warpgate.fullname\" . }}\n  labels:\n    {{- include \"warpgate.labels\" . | nindent 4 }}\n  annotations:\n    nginx.ingress.kubernetes.io/backend-protocol: \"HTTPS\"\n    nginx.ingress.kubernetes.io/proxy-ssl-verify: \"false\"\n  {{- with .Values.ingress.annotations }}\n    {{- toYaml . | nindent 4 }}\n  {{- end }}\nspec:\n  {{- with .Values.ingress.className }}\n  ingressClassName: {{ . }}\n  {{- end }}\n  {{- if .Values.ingress.tls }}\n  tls:\n    {{- range .Values.ingress.tls }}\n    - hosts:\n        {{- range .hosts }}\n        - {{ . | quote }}\n        {{- end }}\n      secretName: {{ .secretName }}\n    {{- end }}\n  {{- end }}\n  rules:\n    {{- range .Values.ingress.hosts }}\n    - host: {{ .host | quote }}\n      http:\n        paths:\n          {{- range .paths }}\n          - path: {{ .path }}\n            {{- with .pathType }}\n            pathType: {{ . }}\n            {{- end }}\n            backend:\n              service:\n                name: {{ include \"warpgate.fullname\" $ }}\n                port:\n                  number: {{ $.Values.service.ports.http }}\n          {{- end }}\n    {{- end }}\n{{- end }}\n"
  },
  {
    "path": "helm/warpgate/templates/pvc.yaml",
    "content": "{{- if and .Values.data.pvc.enabled (not .Values.data.pvc.claimName) }}\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n  name: {{ include \"warpgate.fullname\" . }}-data\n  labels:\n    {{- include \"warpgate.labels\" . | nindent 4 }}\nspec:\n  {{- toYaml .Values.data.pvc.template | nindent 2 }}\n{{- end }}\n"
  },
  {
    "path": "helm/warpgate/templates/service.yaml",
    "content": "apiVersion: v1\nkind: Service\nmetadata:\n  name: {{ include \"warpgate.fullname\" . }}\n  labels:\n    {{- include \"warpgate.labels\" . | nindent 4 }}\n  {{- with .Values.service.annotations }}\n  annotations:\n    {{- toYaml . | nindent 4 }}\n  {{- end}}\nspec:\n  type: {{ .Values.service.type }}\n  ports:\n    {{- with .Values.service.ports.ssh }}\n    {{- if ne (int .) 0 }}\n    - port: {{ . }}\n      targetPort: ssh\n      protocol: TCP\n      name: ssh\n    {{- end }}\n    {{- end }}\n    {{- with .Values.service.ports.http }}\n    {{- if ne (int .) 0 }}\n    - port: {{ . }}\n      targetPort: http\n      protocol: TCP\n      name: http\n    {{- end }}\n    {{- end }}\n    {{- with .Values.service.ports.mysql }}\n    {{- if ne (int .) 0 }}\n    - port: {{ . }}\n      targetPort: mysql\n      protocol: TCP\n      name: mysql\n    {{- end }}\n    {{- end }}\n    {{- with .Values.service.ports.pgsql }}\n    {{- if ne (int .) 0 }}\n    - port: {{ . }}\n      targetPort: pgsql\n      protocol: TCP\n      name: pgsql\n    {{- end }}\n    {{- end }}\n    {{- with .Values.service.ports.kubernetes }}\n    {{- if ne (int .) 0 }}\n    - port: {{ . }}\n      targetPort: kubernetes\n      protocol: TCP\n      name: kubernetes\n    {{- end }}\n    {{- end }}\n  selector:\n    {{- include \"warpgate.selectorLabels\" . | nindent 4 }}\n"
  },
  {
    "path": "helm/warpgate/templates/setup-job.yaml",
    "content": "{{- if .Values.setup.enabled }}\n{{- if eq .Values.setup.type \"job\" }}\napiVersion: batch/v1\nkind: Job\nmetadata:\n  name: {{ include \"warpgate.fullname\" . }}-setup-job\nspec:\n  template:\n    spec:\n      restartPolicy: OnFailure\n      {{- with .Values.podSecurityContext }}\n      securityContext:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      containers:\n        - name: {{ .Chart.Name }}-setup\n          {{- with .Values.securityContext }}\n          securityContext:\n            {{- toYaml . | nindent 12 }}\n          {{- end }}\n          image: \"{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}\"\n          {{- if .Values.setup.envFromSecret }}\n          env:\n          {{- range $key, $val := .Values.setup.envFromSecret }}\n            - name: {{ $key }}\n              valueFrom:\n                secretKeyRef:\n                  name: {{ (split \"/\" $val)._0 }}\n                  key: {{ (split \"/\" $val)._1 }}\n          {{- end }}\n          {{- end }}\n          command:\n            - /bin/sh\n            - -c\n            - |\n              set -e\n\n              if [ -d /ssh-keys ]; then\n                mkdir -p /data/ssh-keys\n                cp /ssh-keys/client-ed25519 /ssh-keys/client-rsa /ssh-keys/host-ed25519 /ssh-keys/host-rsa /data/ssh-keys/\n                chmod -R 600 /data/ssh-keys/*\n              fi\n\n              if [ -d /tls-cert ]; then\n                cp /tls-cert/tls.crt /data/tls.certificate.pem\n                cp /tls-cert/tls.key /data/tls.key.pem\n              fi\n\n              if [ -f /data/warpgate.yaml ]; then\n                # Creates the admin user which is normally only created when the unattended-setup is called. REF: https://github.com/warp-tech/warpgate/issues/1618\n                warpgate -c /data/warpgate.yaml create-user --password \"$WARPGATE_ADMIN_PASSWORD\" --role warpgate:admin admin\n              else\n                warpgate -c /data/warpgate.yaml unattended-setup --data-path /data \\\n                  {{- if .Values.setup.http }} --http-port {{ .Values.setup.http }} {{- end }}\n                  {{- if .Values.setup.ssh }} --ssh-port {{ .Values.setup.ssh }} {{- end }}\n                  {{- if .Values.setup.mysql }} --mysql-port {{ .Values.setup.mysql }} {{- end }}\n                  {{- if .Values.setup.pgsql }} --postgres-port {{ .Values.setup.pgsql }} {{- end }}\n                  {{- if .Values.setup.kubernetes }} --kubernetes-port {{ .Values.setup.kubernetes }} {{- end }}\n                  {{- if .Values.setup.databaseUrl }} --database-url \"{{ .Values.setup.databaseUrl }}\" {{- end }}\n                  {{- if .Values.setup.recordSessions }} --record-sessions {{- end }}\n\n                {{- if .Values.setup.disableIpV6 }}\n                sed -i 's/\\[::\\]:\\([0-9]\\+\\)/0.0.0.0:\\1/g' /data/warpgate.yaml\n                {{- end }}\n              fi\n          volumeMounts:\n            - name: data\n              mountPath: /data\n            {{- if .Values.ssh_keys_secret }}\n            - name: ssh-keys\n              mountPath: /ssh-keys\n            {{- end }}\n            {{- if .Values.tls_cert_secret }}\n            - name: tls-cert\n              mountPath: /tls-cert\n            {{- end }}\n      {{- with .Values.imagePullSecrets }}\n      imagePullSecrets:\n        {{- toYaml . | nindent 8 }}\n      {{- end }}\n      volumes:\n        {{- with .Values.ssh_keys_secret }}\n        - name: ssh-keys\n          secret:\n            secretName: {{ . }}\n        {{- end }}\n        {{- with .Values.tls_cert_secret }}\n        - name: tls-cert\n          secret:\n            secretName: {{ . }}\n        {{- end }}\n        {{- if .Values.data.pvc.enabled }}\n        - name: data\n          persistentVolumeClaim:\n            {{- if .Values.data.pvc.claimName }}\n            claimName: {{ .Values.data.pvc.claimName }}\n            {{- else }}\n            claimName: {{ include \"warpgate.fullname\" . }}-data\n            {{- end }}\n        {{- else }}\n        - name: data\n          emptyDir: {}\n        {{- end }}\n{{- end }}\n{{- end }}\n"
  },
  {
    "path": "helm/warpgate/values.yaml",
    "content": "# Number of replicas for Warpgate deployment\n# Do NOT increase above 1 when using SQLite as the database!\nreplicaCount: 1\n\nimage:\n  repository: ghcr.io/warp-tech/warpgate\n  pullPolicy: IfNotPresent\n  tag: \"0.16.0\"\n\n# References to Kubernetes secrets for pulling images (if using a private registry)\nimagePullSecrets: []\n\n# Extra annotations/labels for deployment or pods\ndeploymentAnnotations: {}\ndeploymentLabels: {}\npodAnnotations: {}\npodLabels: {}\n\n# Override chart name if desired\nnameOverride: \"\"\nfullnameOverride: \"\"\n\n# Pod-level security context (applies to all containers)\npodSecurityContext: {}\n  # fsGroup: 2000\n\n# Container-level security context\nsecurityContext: {}\n  # capabilities:\n  #   drop: [ \"ALL\" ]\n  # readOnlyRootFilesystem: true\n  # runAsNonRoot: true\n  # runAsUser: 1000\n\n# Resource requests/limits for the Warpgate container\nresources: {}\n  # limits:\n  #   cpu: 100m\n  #   memory: 128Mi\n  # requests:\n  #   cpu: 100m\n  #   memory: 128Mi\n\n# Node scheduling options\nnodeSelector: {}\ntolerations: []\naffinity: {}\n\n# Secret with SSH host/client private keys:\n# expected keys: client-ed25519, client-rsa, host-ed25519, host-rsa\n# If not provided, Warpgate will generate them inside the PVC\nssh_keys_secret: \"\"\n\n# Secret containing TLS certs (tls.key, tls.crt)\ntls_cert_secret: \"\"\n\n# Provide a full Warpgate config to override the auto-generated one\n# ⚠️ If set, ensure ports match the setup/service config below\noverrides_config: \"\"\n\n# Space-separated list of environment variable names to substitute in the config\n# Example: \"FOO BAR\" → replaces $FOO and $BAR from pod environment\nconfig_env_var_replace: \"\"\n\n# Additional custom volumes & mounts (advanced use-cases)\nvolumes: []\nvolume_mounts: []\n\n# Persistence configuration\n#  If disabled and no external DB is used, all data will be lost on pod restart!\n#  If you are using config_overrides, ca, ssh keyds form secrets and external databse you can use emptyDir\ndata:\n  pvc:\n    enabled: false          # true = use PersistentVolumeClaim, false = use emptyDir\n    claimName: \"\"           # if set, uses existing PVC instead of template\n    template: {}            # PVC template spec for dynamic provisioning\n\n# Automatic Warpgate setup\n# Ref: https://warpgate.null.page/getting-started/#setup\nsetup:\n  enabled: true\n\n  # Setup mode:\n  # - job: (recommended) one-time Kubernetes Job runs setup\n  # - podinit: initContainer mode (use only if you don't persist data via PVC)\n  type: \"job\"\n\n  # Secret containing initial admin password\n  # Format: <secret-name>/<key>\n  envFromSecret:\n    WARPGATE_ADMIN_PASSWORD: \"warpgate-secret/adminPassword\"\n\n  # Config options (overridden if overrides_config is used)\n  disableIpV6: false\n  recordSessions: true\n  databaseUrl: \"\"   # Optional: PostgreSQL connection string\n\n  # Ports to expose. Set to 0 or false to disable protocol.\n  ssh: 2222\n  http: 8888\n  mysql: 33306\n  pgsql: 55432\n  kubernetes: 0\n  # kubernetes: 6443\n\n  # Resources for init container (runs setup)\n  resources: {}\n    # limits:\n    #   cpu: 100m\n    #   memory: 128Mi\n    # requests:\n    #   cpu: 100m\n    #   memory: 128Mi\n\n# Kubernetes Service definition\nservice:\n  annotations: {}\n  type: ClusterIP\n  ports:\n    ssh: 2222\n    http: 8888\n    mysql: 33306\n    pgsql: 55432\n    kubernetes: 0\n    # kubernetes: 6443\n\n# Ingress configuration\ningress:\n  enabled: false\n  className: \"nginx\"\n  annotations:\n    cert-manager.io/cluster-issuer: default-issuer\n    kubernetes.io/ingress.class: nginx\n    # kubernetes.io/tls-acme: \"true\"\n  hosts: []\n  tls: []\n\n# HTTPRoute configuration\nhttpRoute:\n  enabled: false\n  annotations: {}\n  parentRefs: []\n  # - name: gateway-name\n  #   namespace: gateway-namespace\n  #   sectionName: gateway-section-name\n  hostnames: []\n  # - warpgate.example.com\n"
  },
  {
    "path": "justfile",
    "content": "projects := \"warpgate warpgate-admin warpgate-common warpgate-db-entities warpgate-db-migrations warpgate-database-protocols warpgate-protocol-ssh warpgate-protocol-mysql warpgate-protocol-postgres warpgate-protocol-kubernetes warpgate-protocol-http warpgate-core warpgate-sso\"\n\nrun $RUST_BACKTRACE='1' *ARGS='run':\n     cargo run --all-features -- --config config.yaml {{ARGS}}\n\nfmt:\n    for p in {{projects}}; do cargo fmt -p $p -v; done\n\nfix *ARGS:\n    for p in {{projects}}; do cargo fix --all-features -p $p {{ARGS}}; done\n\nclippy *ARGS:\n    for p in {{projects}}; do cargo cranky --all-features -p $p {{ARGS}}; done\n\ntest:\n    for p in {{projects}}; do cargo test --all-features -p $p; done\n\nnpm *ARGS:\n    cd warpgate-web && npm {{ARGS}}\n\nnpx *ARGS:\n    cd warpgate-web && npx {{ARGS}}\n\nmigrate *ARGS:\n    cargo run --all-features -p warpgate-db-migrations -- {{ARGS}}\n\nlint *ARGS:\n    cd warpgate-web && npm run lint {{ARGS}}\n\nsvelte-check:\n    cd warpgate-web && npm run check\n\nopenapi-all:\n    cd warpgate-web && npm run openapi:schema:admin && npm run openapi:schema:gateway && npm run openapi:client:admin && npm run openapi:client:gateway\n\nopenapi:\n    cd warpgate-web && npm run openapi:client:admin && npm run openapi:client:gateway\n\nconfig-schema:\n    cargo run -p warpgate-common --bin config-schema > config-schema.json\n\ncleanup: (fix \"--allow-dirty\") (clippy \"--fix\" \"--allow-dirty\") fmt svelte-check lint\n\nudeps:\n    cargo udeps --all-features --all-targets\n"
  },
  {
    "path": "rust-toolchain",
    "content": "nightly-2025-10-21\n"
  },
  {
    "path": "rustfmt.toml",
    "content": "imports_granularity = \"Module\"\ngroup_imports = \"StdExternalCrate\"\n"
  },
  {
    "path": "sonar-project.properties",
    "content": "sonar.projectKey=warp-tech_warpgate\nsonar.organization=warp-tech\n\nsonar.sources=.\nsonar.inclusions=warpgate-*/**/*\n\nsonar.rust.lcov.reportPaths=coverage.lcov\n"
  },
  {
    "path": "tests/.gitignore",
    "content": "api_sdk\n"
  },
  {
    "path": "tests/Makefile",
    "content": "image-ssh-server:\n\tcd images/ssh-server && docker build -t warpgate-e2e-ssh-server .\n\nimage-mysql-server:\n\tcd images/mysql-server && docker build -t warpgate-e2e-mysql-server .\n\nimage-postgres-server:\n\tcd images/postgres-server && docker build -t warpgate-e2e-postgres-server .\n\nall: image-ssh-server image-mysql-server image-postgres-server\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/api_client.py",
    "content": "from contextlib import contextmanager\n\ntry:\n    # in-IDE\n    import api_sdk.openapi_client as sdk\nexcept ImportError:\n    import openapi_client as sdk\n\n\n@contextmanager\ndef admin_client(host, token=\"token-value\"):\n    config = sdk.Configuration(\n        host=f\"{host}/@warpgate/admin/api\",\n        api_key={\n            \"TokenSecurityScheme\": token,\n        },\n    )\n    config.verify_ssl = False\n    with sdk.ApiClient(config) as api_client:\n        yield sdk.DefaultApi(api_client)\n"
  },
  {
    "path": "tests/certs/tls.certificate.pem",
    "content": "-----BEGIN CERTIFICATE-----\r\nMIIBYjCCAQmgAwIBAgIJAKXIp8GepnCzMAoGCCqGSM49BAMCMCExHzAdBgNVBAMM\r\nFnJjZ2VuIHNlbGYgc2lnbmVkIGNlcnQwIBcNNzUwMTAxMDAwMDAwWhgPNDA5NjAx\r\nMDEwMDAwMDBaMCExHzAdBgNVBAMMFnJjZ2VuIHNlbGYgc2lnbmVkIGNlcnQwWTAT\r\nBgcqhkjOPQIBBggqhkjOPQMBBwNCAARAtRfTqyH8+eXf12Vftm6VcMhhYG6Ape3O\r\ntcLfIWJo1krsOP+96r5U20ya7YVVFmYFPoQToAOoio2dxlX3jOL/oygwJjAkBgNV\r\nHREEHTAbgg53YXJwZ2F0ZS5sb2NhbIIJbG9jYWxob3N0MAoGCCqGSM49BAMCA0cA\r\nMEQCICTt3I/PsgF8Rvu6aKwY2LTouZyxReDMiCePzsqdAxXAAiATNw61MBylNaAF\r\nFGkPqR0VZIR6sIFHZnib9JQNhka2Fg==\r\n-----END CERTIFICATE-----\r\n"
  },
  {
    "path": "tests/certs/tls.key.pem",
    "content": "-----BEGIN PRIVATE KEY-----\r\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg0tJmr/OSF7neTQOV\r\ngQn+qHCdVsOENdMc86RlWPiWDlKhRANCAARAtRfTqyH8+eXf12Vftm6VcMhhYG6A\r\npe3OtcLfIWJo1krsOP+96r5U20ya7YVVFmYFPoQToAOoio2dxlX3jOL/\r\n-----END PRIVATE KEY-----\r\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "import logging\nimport os\nimport time\nimport psutil\nimport pytest\nimport requests\nimport shutil\nimport signal\nimport subprocess\nimport tempfile\nimport urllib3\nimport uuid\nimport base64\n\n# cryptography is used to generate client certificates/CSRs locally\nfrom cryptography import x509\nfrom cryptography.hazmat.primitives import hashes, serialization\nfrom cryptography.hazmat.primitives.asymmetric import rsa\nfrom cryptography.x509.oid import NameOID\n\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom textwrap import dedent\nfrom typing import List, Optional\n\nfrom deepmerge import always_merger\n\nfrom .util import _wait_timeout, alloc_port, wait_port\nfrom .test_http_common import echo_server_port  # noqa\n\n\ncargo_root = Path(os.getcwd()).parent\nenable_coverage = os.getenv(\"ENABLE_COVERAGE\", \"0\") == \"1\"\nbinary_path = (\n    \"target/llvm-cov-target/debug/warpgate\"\n    if enable_coverage\n    else \"target/debug/warpgate\"\n)\n\n\n@dataclass\nclass Context:\n    tmpdir: Path\n\n\n@dataclass\nclass K3sInstance:\n    port: int\n    token: str\n    container_name: str\n    client_cert: str\n    client_key: str\n\n    def kubectl(self, cmd_args, input=None, check=True):\n        ret = subprocess.run(\n            [\"docker\", \"exec\", \"-i\", self.container_name, \"kubectl\", *cmd_args],\n            input=input,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n        )\n        if check:\n            try:\n                ret.check_returncode()\n            except subprocess.CalledProcessError as e:\n                logging.error(\n                    f\"kubectl command failed: {' '.join(cmd_args)}\\nstdout: {e.stdout.decode()}\\nstderr: {e.stderr.decode()}\"\n                )\n                raise\n        return ret\n\n\n@dataclass\nclass Child:\n    process: subprocess.Popen\n    stop_signal: signal.Signals\n    stop_timeout: float\n\n\n@dataclass\nclass WarpgateProcess:\n    config_path: Path\n    process: subprocess.Popen\n    http_port: int\n    ssh_port: int\n    mysql_port: int\n    postgres_port: int\n    kubernetes_port: int\n\n\nclass ProcessManager:\n    children: List[Child]\n\n    def __init__(self, ctx: Context, timeout: int) -> None:\n        self.children = []\n        self.ctx = ctx\n        self.timeout = timeout\n\n    def stop(self):\n        for child in self.children:\n            try:\n                p = psutil.Process(child.process.pid)\n            except psutil.NoSuchProcess:\n                continue\n\n            p.send_signal(child.stop_signal)\n\n            for sp in p.children(recursive=True):\n                try:\n                    sp.terminate()\n                except psutil.NoSuchProcess:\n                    pass\n\n            try:\n                p.wait(timeout=child.stop_timeout)\n            except psutil.TimeoutExpired:\n                for sp in p.children(recursive=True):\n                    try:\n                        sp.kill()\n                    except psutil.NoSuchProcess:\n                        pass\n                p.kill()\n\n    def start_ssh_server(self, trusted_keys=[], extra_config=\"\"):\n        port = alloc_port()\n        data_dir = self.ctx.tmpdir / f\"sshd-{uuid.uuid4()}\"\n        data_dir.mkdir(parents=True)\n        authorized_keys_path = data_dir / \"authorized_keys\"\n        authorized_keys_path.write_text(\"\\n\".join(trusted_keys))\n        config_path = data_dir / \"sshd_config\"\n        config_path.write_text(\n            dedent(\n                f\"\"\"\\\n                Port 22\n                AuthorizedKeysFile {authorized_keys_path}\n                AllowAgentForwarding yes\n                AllowTcpForwarding yes\n                GatewayPorts yes\n                X11Forwarding yes\n                UseDNS no\n                PermitTunnel yes\n                StrictModes no\n                PermitRootLogin yes\n                HostKey /ssh-keys/id_ed25519\n                Subsystem\tsftp\t/usr/lib/ssh/sftp-server\n                LogLevel DEBUG3\n                {extra_config}\n                \"\"\"\n            )\n        )\n        data_dir.chmod(0o700)\n        authorized_keys_path.chmod(0o600)\n        config_path.chmod(0o600)\n\n        self.start(\n            [\n                \"docker\",\n                \"run\",\n                \"--rm\",\n                \"-p\",\n                f\"{port}:22\",\n                \"-v\",\n                f\"{data_dir}:{data_dir}\",\n                \"-v\",\n                f\"{os.getcwd()}/ssh-keys:/ssh-keys\",\n                \"warpgate-e2e-ssh-server\",\n                \"-f\",\n                str(config_path),\n            ]\n        )\n        return port\n\n    def start_mysql_server(self):\n        port = alloc_port()\n        self.start(\n            [\"docker\", \"run\", \"--rm\", \"-p\", f\"{port}:3306\", \"warpgate-e2e-mysql-server\"]\n        )\n        return port\n\n    def start_postgres_server(self):\n        port = alloc_port()\n        container_name = f\"warpgate-e2e-postgres-server-{uuid.uuid4()}\"\n        self.start(\n            [\n                \"docker\",\n                \"run\",\n                \"--rm\",\n                \"--name\",\n                container_name,\n                \"-p\",\n                f\"{port}:5432\",\n                \"warpgate-e2e-postgres-server\",\n            ]\n        )\n\n        def wait_postgres():\n            while True:\n                try:\n                    subprocess.check_call(\n                        [\n                            \"docker\",\n                            \"exec\",\n                            container_name,\n                            \"pg_isready\",\n                            \"-h\",\n                            \"localhost\",\n                            \"-U\",\n                            \"user\",\n                        ]\n                    )\n                    break\n                except subprocess.CalledProcessError:\n                    time.sleep(1)\n\n        _wait_timeout(wait_postgres, \"Postgres is not ready\", timeout=self.timeout)\n        logging.debug(f\"Postgres {container_name} is up\")\n        return port\n\n    def start_k3s(self) -> K3sInstance:\n        \"\"\"\n        Runs a privileged k3s container, waits for the API to be ready,\n        creates a ServiceAccount and clusterrolebinding, then uses\n        `kubectl create token` to fetch the bearer token. Assumes a modern\n        k8s version (no fallback logic needed).\n        \"\"\"\n        port = alloc_port()\n        container_name = f\"warpgate-e2e-k3s-{uuid.uuid4()}\"\n        image = os.getenv(\"K3S_IMAGE\", \"rancher/k3s:v1.35.2-k3s1\")\n\n        self.start(\n            [\n                \"docker\",\n                \"run\",\n                \"--rm\",\n                \"--name\",\n                container_name,\n                \"--privileged\",\n                \"-p\",\n                f\"{port}:6443\",\n                image,\n                \"server\",\n                \"--disable\",\n                \"traefik\",\n            ]\n        )\n\n        def wait_k3s():\n            # Wait until kube-apiserver is responding\n            while True:\n                try:\n                    subprocess.check_call(\n                        [\n                            \"docker\",\n                            \"exec\",\n                            container_name,\n                            \"kubectl\",\n                            \"get\",\n                            \"nodes\",\n                        ],\n                        stdout=subprocess.DEVNULL,\n                        stderr=subprocess.DEVNULL,\n                    )\n                    break\n                except subprocess.CalledProcessError:\n                    time.sleep(1)\n\n        _wait_timeout(wait_k3s, \"k3s API is not ready\", timeout=self.timeout * 5)\n\n        # k3s sometimes returns OK for `get nodes` before namespace controller\n        # has created the \"default\" namespace.  make sure it exists before we\n        # try to create objects inside it.\n        def wait_default_ns():\n            while True:\n                r = subprocess.run(\n                    [\n                        \"docker\",\n                        \"exec\",\n                        container_name,\n                        \"kubectl\",\n                        \"get\",\n                        \"namespace\",\n                        \"default\",\n                    ],\n                    stdout=subprocess.DEVNULL,\n                    stderr=subprocess.DEVNULL,\n                )\n                if r.returncode:\n                    time.sleep(1)\n                else:\n                    break\n\n        _wait_timeout(\n            wait_default_ns, \"default namespace is not ready\", timeout=self.timeout * 5\n        )\n\n        # Create service account inside the container\n        subprocess.check_call(\n            [\n                \"docker\",\n                \"exec\",\n                container_name,\n                \"kubectl\",\n                \"create\",\n                \"serviceaccount\",\n                \"test-sa\",\n                \"-n\",\n                \"default\",\n            ]\n        )\n\n        # Assign cluster admin role so our SA can do anything\n        subprocess.check_call(\n            [\n                \"docker\",\n                \"exec\",\n                container_name,\n                \"kubectl\",\n                \"create\",\n                \"clusterrolebinding\",\n                \"test-sa-binding\",\n                \"--clusterrole=cluster-admin\",\n                \"--serviceaccount=default:test-sa\",\n            ]\n        )\n\n        token = (\n            subprocess.check_output(\n                [\n                    \"docker\",\n                    \"exec\",\n                    container_name,\n                    \"kubectl\",\n                    \"create\",\n                    \"token\",\n                    \"test-sa\",\n                    \"-n\",\n                    \"default\",\n                ],\n                stderr=subprocess.DEVNULL,\n            )\n            .decode()\n            .strip()\n        )\n\n        # generate a client key and CSR locally, then ask the k3s CA to sign it\n        key = rsa.generate_private_key(public_exponent=65537, key_size=2048)\n        csr = (\n            x509.CertificateSigningRequestBuilder()\n            .subject_name(\n                x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, \"system:masters\")])\n            )\n            .sign(key, hashes.SHA256())\n        )\n        client_key = key.private_bytes(\n            encoding=serialization.Encoding.PEM,\n            format=serialization.PrivateFormat.TraditionalOpenSSL,\n            encryption_algorithm=serialization.NoEncryption(),\n        ).decode()\n        csr_pem = csr.public_bytes(serialization.Encoding.PEM)\n\n        # create the CSR resource inside the cluster using kubectl\n        csr_name = \"wg-client\"\n        csr_yaml = dedent(\n            f\"\"\"\n            apiVersion: certificates.k8s.io/v1\n            kind: CertificateSigningRequest\n            metadata:\n              name: {csr_name}\n            spec:\n              groups:\n              - system:authenticated\n              - system:masters\n              request: {base64.b64encode(csr_pem).decode()}\n              signerName: kubernetes.io/kube-apiserver-client\n              usages:\n              - client auth\n            \"\"\"\n        )\n        subprocess.run(\n            [\n                \"docker\",\n                \"exec\",\n                \"-i\",\n                container_name,\n                \"sh\",\n                \"-c\",\n                \"kubectl apply -f -\",\n            ],\n            input=csr_yaml.encode(),\n            check=True,\n        )\n        subprocess.check_call(\n            [\n                \"docker\",\n                \"exec\",\n                container_name,\n                \"kubectl\",\n                \"certificate\",\n                \"approve\",\n                csr_name,\n            ]\n        )\n\n        # after approving the CSR the certificate may take a moment to\n        # appear in the resource status\n        def fetch_cert() -> str:\n            while True:\n                cert = subprocess.check_output(\n                    [\n                        \"docker\",\n                        \"exec\",\n                        container_name,\n                        \"sh\",\n                        \"-c\",\n                        (\n                            f\"kubectl get csr {csr_name} -o jsonpath='{{.status.certificate}}' \"\n                            \"| base64 -d\"\n                        ),\n                    ]\n                ).decode()\n                if cert:\n                    return cert\n                time.sleep(0.1)\n\n        client_cert = \"\"\n        _wait_timeout(\n            fetch_cert,\n            \"k3s did not sign CSR\",\n            timeout=self.timeout,\n        )\n\n        client_cert = fetch_cert()\n\n        logging.debug(\"retrieved signed client certificate from k3s\")\n\n        # the cert subject is \"system:masters\" so bind that user.\n        subprocess.check_call(\n            [\n                \"docker\",\n                \"exec\",\n                container_name,\n                \"kubectl\",\n                \"create\",\n                \"clusterrolebinding\",\n                \"wg-cert-binding\",\n                \"--clusterrole=cluster-admin\",\n                \"--user=system:masters\",\n            ]\n        )\n\n        logging.debug(f\"k3s {container_name} is up on port {port}\")\n        return K3sInstance(\n            port=port,\n            token=token,\n            container_name=container_name,\n            client_cert=client_cert,\n            client_key=client_key,\n        )\n\n    def start_oidc_server(\n        self,\n        warpgate_http_port,\n        extra_scopes=None,\n        users_override=None,\n        extra_identity_resources=None,\n    ):\n        port = alloc_port()\n        container_name = f\"warpgate-e2e-oidc-mock-{uuid.uuid4()}\"\n\n        oidc_data_dir = self.ctx.tmpdir / f\"oidc-{uuid.uuid4()}\"\n        oidc_data_dir.mkdir(parents=True)\n\n        import json as _json\n\n        allowed_scopes = [\n            \"openid\",\n            \"profile\",\n            \"email\",\n            \"preferred_username\",\n        ]\n        if extra_scopes:\n            allowed_scopes.extend(extra_scopes)\n\n        clients_config = [\n            {\n                \"ClientId\": \"warpgate-test\",\n                \"ClientSecrets\": [\"warpgate-test-secret\"],\n                \"AllowedGrantTypes\": [\"authorization_code\"],\n                \"AllowedScopes\": allowed_scopes,\n                \"ClientClaimsPrefix\": \"\",\n                \"RedirectUris\": [\n                    f\"https://127.0.0.1:{warpgate_http_port}/@warpgate/api/sso/return\"\n                ],\n            }\n        ]\n\n        clients_config_path = oidc_data_dir / \"clients-config.json\"\n        with open(clients_config_path, \"w\") as f:\n            _json.dump(clients_config, f)\n\n        server_options = _json.dumps(\n            {\n                \"AccessTokenJwtType\": \"JWT\",\n                \"Discovery\": {\"ShowKeySet\": True},\n                \"Authentication\": {\n                    \"CookieSameSiteMode\": \"Lax\",\n                    \"CheckSessionCookieSameSiteMode\": \"Lax\",\n                },\n            }\n        )\n        default_users = [\n            {\n                \"SubjectId\": \"1\",\n                \"Username\": \"User1\",\n                \"Password\": \"pwd\",\n                \"Claims\": [\n                    {\n                        \"Type\": \"name\",\n                        \"Value\": \"Sam Tailor\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"email\",\n                        \"Value\": \"sam.tailor@gmail.com\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"preferred_username\",\n                        \"Value\": \"sam_tailor\",\n                        \"ValueType\": \"string\",\n                    },\n                ],\n            }\n        ]\n        users_config = _json.dumps(\n            users_override if users_override is not None else default_users\n        )\n        identity_resources_list = [\n            {\"Name\": \"preferred_username\", \"ClaimTypes\": [\"preferred_username\"]},\n        ]\n        if extra_identity_resources:\n            identity_resources_list.extend(extra_identity_resources)\n        identity_resources = _json.dumps(identity_resources_list)\n\n        self.start(\n            [\n                \"docker\",\n                \"run\",\n                \"--rm\",\n                \"--name\",\n                container_name,\n                \"-p\",\n                f\"{port}:8080\",\n                \"-e\",\n                \"ASPNETCORE_ENVIRONMENT=Development\",\n                \"-e\",\n                f\"SERVER_OPTIONS_INLINE={server_options}\",\n                \"-e\",\n                'LOGIN_OPTIONS_INLINE={\"AllowRememberLogin\": true}',\n                \"-e\",\n                f\"USERS_CONFIGURATION_INLINE={users_config}\",\n                \"-e\",\n                f\"IDENTITY_RESOURCES_INLINE={identity_resources}\",\n                \"-e\",\n                \"CLIENTS_CONFIGURATION_PATH=/tmp/config/clients-config.json\",\n                \"-v\",\n                f\"{oidc_data_dir}:/tmp/config:ro\",\n                \"ghcr.io/soluto/oidc-server-mock:0.10.1\",\n            ]\n        )\n\n        def wait_oidc():\n            import urllib3\n\n            urllib3.disable_warnings()\n            while True:\n                try:\n                    r = requests.get(\n                        f\"http://localhost:{port}/.well-known/openid-configuration\",\n                        timeout=2,\n                    )\n                    if r.status_code == 200:\n                        break\n                except Exception:\n                    pass\n                time.sleep(0.5)\n\n        _wait_timeout(wait_oidc, \"OIDC mock is not ready\", timeout=self.timeout * 3)\n        logging.debug(f\"OIDC mock {container_name} is up on port {port}\")\n        return port\n\n    def start_wg(\n        self,\n        config_patch=None,\n        args=None,\n        share_with: Optional[WarpgateProcess] = None,\n        stderr=None,\n        stdout=None,\n        http_port=None,\n    ) -> WarpgateProcess:\n        args = args or [\"run\", \"--enable-admin-token\"]\n\n        if share_with:\n            config_path = share_with.config_path\n            ssh_port = share_with.ssh_port\n            mysql_port = share_with.mysql_port\n            postgres_port = share_with.postgres_port\n            http_port = share_with.http_port\n            kubernetes_port = share_with.kubernetes_port\n        else:\n            ssh_port = alloc_port()\n            http_port = http_port or alloc_port()\n            mysql_port = alloc_port()\n            postgres_port = alloc_port()\n            kubernetes_port = alloc_port()\n\n            data_dir = self.ctx.tmpdir / f\"wg-data-{uuid.uuid4()}\"\n            data_dir.mkdir(parents=True)\n\n            keys_dir = data_dir / \"ssh-keys\"\n            keys_dir.mkdir(parents=True)\n            for k in [\n                Path(\"ssh-keys/wg/client-ed25519\"),\n                Path(\"ssh-keys/wg/client-rsa\"),\n                Path(\"ssh-keys/wg/host-ed25519\"),\n                Path(\"ssh-keys/wg/host-rsa\"),\n            ]:\n                shutil.copy(k, keys_dir / k.name)\n\n            for k in [\n                Path(\"certs/tls.certificate.pem\"),\n                Path(\"certs/tls.key.pem\"),\n            ]:\n                shutil.copy(k, data_dir / k.name)\n\n            config_path = data_dir / \"warpgate.yaml\"\n\n        def run(args, env={}):\n            return self.start(\n                [\n                    os.path.join(cargo_root, binary_path),\n                    \"--config\",\n                    str(config_path),\n                    *args,\n                ],\n                cwd=cargo_root,\n                env={\n                    **os.environ,\n                    \"LLVM_PROFILE_FILE\": f\"{cargo_root}/target/llvm-cov-target/warpgate-%m.profraw\",\n                    \"WARPGATE_ADMIN_TOKEN\": \"token-value\",\n                    \"WARPGATE_UNDER_TEST\": \"1\",\n                    **env,\n                },\n                stop_signal=signal.SIGINT,\n                stop_timeout=5,\n                stderr=stderr,\n                stdout=stdout,\n            )\n\n        if not share_with:\n            p = run(\n                [\n                    \"unattended-setup\",\n                    \"--ssh-port\",\n                    str(ssh_port),\n                    \"--http-port\",\n                    str(http_port),\n                    \"--mysql-port\",\n                    str(mysql_port),\n                    \"--postgres-port\",\n                    str(postgres_port),\n                    \"--kubernetes-port\",\n                    str(kubernetes_port),\n                    \"--data-path\",\n                    data_dir,\n                    \"--external-host\",\n                    \"external-host\",\n                ],\n                env={\"WARPGATE_ADMIN_PASSWORD\": \"123\"},\n            )\n            p.communicate()\n\n            assert p.returncode == 0\n\n            import yaml\n\n            config = yaml.safe_load(config_path.open())\n            config[\"ssh\"][\"host_key_verification\"] = \"auto_accept\"\n            if config_patch:\n                always_merger.merge(config, config_patch)\n            with config_path.open(\"w\") as f:\n                yaml.safe_dump(config, f)\n\n        p = run(args)\n        return WarpgateProcess(\n            process=p,\n            config_path=config_path,\n            ssh_port=ssh_port,\n            http_port=http_port,\n            mysql_port=mysql_port,\n            postgres_port=postgres_port,\n            kubernetes_port=kubernetes_port,\n        )\n\n    def start_ssh_client(self, *args, password=None, **kwargs):\n        preargs = []\n        if password:\n            preargs = [\"sshpass\", \"-p\", password]\n        p = self.start(\n            [\n                *preargs,\n                \"ssh\",\n                # '-v',\n                \"-o\",\n                \"IdentitiesOnly=yes\",\n                \"-o\",\n                \"StrictHostKeychecking=no\",\n                \"-o\",\n                \"UserKnownHostsFile=/dev/null\",\n                *args,\n            ],\n            stdin=subprocess.PIPE,\n            stdout=subprocess.PIPE,\n            **kwargs,\n        )\n        return p\n\n    def start(self, args, stop_timeout=3, stop_signal=signal.SIGTERM, **kwargs):\n        p = subprocess.Popen(args, **kwargs)\n        self.children.append(\n            Child(process=p, stop_signal=stop_signal, stop_timeout=stop_timeout)\n        )\n        return p\n\n\n@pytest.fixture(scope=\"session\")\ndef timeout():\n    t = os.getenv(\"TIMEOUT\", \"10\")\n    return int(t)\n\n\n@pytest.fixture(scope=\"session\")\ndef ctx():\n    with tempfile.TemporaryDirectory() as tmpdir:\n        ctx = Context(tmpdir=Path(tmpdir))\n        yield ctx\n\n\n@pytest.fixture(scope=\"session\")\ndef processes(ctx, timeout, report_generation):\n    mgr = ProcessManager(ctx, timeout)\n    try:\n        yield mgr\n    finally:\n        mgr.stop()\n\n\n@pytest.fixture(scope=\"session\", autouse=True)\ndef report_generation():\n    if not enable_coverage:\n        yield None\n        return\n    # subprocess.call(['cargo', 'llvm-cov', 'clean', '--workspace'])\n    subprocess.check_call(\n        [\n            \"cargo\",\n            \"llvm-cov\",\n            \"run\",\n            \"--no-cfg-coverage-nightly\",\n            \"--all-features\",\n            \"--no-report\",\n            \"--\",\n            \"version\",\n        ],\n        cwd=cargo_root,\n    )\n    yield\n    # subprocess.check_call(['cargo', 'llvm-cov', '--no-run', '--hide-instantiations', '--html'], cwd=cargo_root)\n\n\n@pytest.fixture(scope=\"session\")\ndef shared_wg(processes: ProcessManager):\n    wg = processes.start_wg()\n    wait_port(wg.http_port, for_process=wg.process, recv=False)\n    wait_port(wg.ssh_port, for_process=wg.process)\n    wait_port(wg.kubernetes_port, for_process=wg.process, recv=False)\n    yield wg\n\n\n# sometimes tests just want a pre‑configured API client for the admin\n# endpoint.  previously everyone called ``admin_client(url)`` directly;\n# a fixture lets us compute the URL from ``shared_wg`` once and removes\n# boilerplate from individual tests.\nfrom .api_client import admin_client as _admin_client_context\n\n\n@pytest.fixture\ndef admin_client(shared_wg: WarpgateProcess):\n    \"\"\"Yields a ``sdk.DefaultApi`` instance authenticated with the\n    built-in token and pointing at the running warpgate instance.\n\n    Usage::\n\n        def test_something(shared_wg, admin_client):\n            user = admin_client.create_user(...)\n    \"\"\"\n    url = f\"https://localhost:{shared_wg.http_port}\"\n    with _admin_client_context(url) as api:\n        yield api\n\n\n# ----\n\n\n@pytest.fixture(scope=\"session\")\ndef wg_c_ed25519_pubkey():\n    return Path(os.getcwd()) / \"ssh-keys/wg/client-ed25519.pub\"\n\n\n@pytest.fixture(scope=\"session\")\ndef wg_c_rsa_pubkey():\n    return Path(os.getcwd()) / \"ssh-keys/wg/client-rsa.pub\"\n\n\n@pytest.fixture(scope=\"session\")\ndef otp_key_base64():\n    return \"Isj0ekwF1YsKW8VUUQiU4awp/9dMnyMcTPH9rlr1OsE=\"\n\n\n@pytest.fixture(scope=\"session\")\ndef otp_key_base32():\n    return \"ELEPI6SMAXKYWCS3YVKFCCEU4GWCT76XJSPSGHCM6H624WXVHLAQ\"\n\n\n@pytest.fixture(scope=\"session\")\ndef password_123_hash():\n    return \"$argon2id$v=19$m=4096,t=3,p=1$cxT6YKZS7r3uBT4nPJXEJQ$GhjTXyGi5vD2H/0X8D3VgJCZSXM4I8GiXRzl4k5ytk0\"\n\n\nlogging.basicConfig(level=logging.DEBUG)\nrequests.packages.urllib3.disable_warnings()\nurllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\nsubprocess.call(\"chmod 600 ssh-keys/id*\", shell=True)\n"
  },
  {
    "path": "tests/images/mysql-server/Dockerfile",
    "content": "FROM mariadb:10.8@sha256:456709ab146585d6189da05669b84384518baecd83670c9e5221f8c20a47cf1e\n\nENV MYSQL_DATABASE=db\nENV MYSQL_ROOT_PASSWORD=123\n\nADD init.sql /docker-entrypoint-initdb.d\n"
  },
  {
    "path": "tests/images/mysql-server/init.sql",
    "content": "CREATE TABLE `db`.`table` (\n  `id` int(11) NOT NULL,\n  `name` varchar(1023) NOT NULL\n) ENGINE=InnoDB;\n"
  },
  {
    "path": "tests/images/postgres-server/Dockerfile",
    "content": "FROM postgres:17.0@sha256:f176fef320ed02c347e9f85352620945547a9a23038f02b57cf7939a198182ae\n\nENV POSTGRES_DB=db\nENV POSTGRES_USER=user\nENV POSTGRES_PASSWORD=123\n\nADD init.sql /docker-entrypoint-initdb.d\n"
  },
  {
    "path": "tests/images/postgres-server/init.sql",
    "content": "CREATE TABLE tbl (\n  id int NOT NULL,\n  name text NOT NULL\n);\n"
  },
  {
    "path": "tests/images/ssh-server/Dockerfile",
    "content": "FROM alpine:3.14@sha256:0f2d5c38dd7a4f4f733e688e3a6733cb5ab1ac6e3cb4603a5dd564e5bfb80eed\nRUN apk add openssh curl\nRUN passwd -u root\nENTRYPOINT [\"/usr/sbin/sshd\", \"-De\"]\n"
  },
  {
    "path": "tests/oidc-mock/clients-config.json",
    "content": "[\n  {\n    \"ClientId\": \"implicit-mock-client\",\n    \"Description\": \"Client for implicit flow\",\n    \"AllowedGrantTypes\": [\n      \"implicit\"\n    ],\n    \"AllowAccessTokensViaBrowser\": true,\n    \"RedirectUris\": [\n      \"https://warpgate.com/@warpgate/api/sso/return\",\n      \"https://127.0.0.1:8888/@warpgate/api/sso/return\"\n    ],\n    \"AllowedScopes\": [\n      \"openid\",\n      \"profile\",\n      \"email\",\n      \"warpgate-scope\",\n      \"preferred_username\"\n    ],\n    \"IdentityTokenLifetime\": 3600,\n    \"AccessTokenLifetime\": 3600\n  },\n  {\n    \"ClientId\": \"client-credentials-mock-client\",\n    \"ClientSecrets\": [\n      \"client-credentials-mock-client-secret\"\n    ],\n    \"Description\": \"Client for client credentials flow\",\n    \"AllowedGrantTypes\": [\n      \"authorization_code\"\n    ],\n    \"AllowedScopes\": [\n      \"openid\",\n      \"profile\",\n      \"email\",\n      \"warpgate-scope\",\n      \"preferred_username\"\n    ],\n    \"ClientClaimsPrefix\": \"\",\n    \"RedirectUris\": [\n      \"https://warpgate.com/@warpgate/api/sso/return\",\n      \"https://127.0.0.1:8888/@warpgate/api/sso/return\"\n    ],\n    \"Claims\": [\n      {\n        \"Type\": \"string_claim\",\n        \"Value\": \"string_claim_value\",\n        \"ValueType\": \"string\"\n      },\n      {\n        \"Type\": \"json_claim\",\n        \"Value\": \"[\\\"value1\\\", \\\"value2\\\"]\",\n        \"ValueType\": \"json\"\n      }\n    ]\n  }\n]\n"
  },
  {
    "path": "tests/oidc-mock/docker-compose.yml",
    "content": "version: '3'\nservices:\n  oidc-server-mock:\n    container_name: oidc-server-mock\n    image: ghcr.io/soluto/oidc-server-mock:0.10.1\n    platform: linux/amd64\n    ports:\n      - '4011:8080'\n    environment:\n      ASPNETCORE_ENVIRONMENT: Development\n      SERVER_OPTIONS_INLINE: |\n        {\n          \"AccessTokenJwtType\": \"JWT\",\n          \"Discovery\": {\n            \"ShowKeySet\": true\n          },\n          \"Authentication\": {\n            \"CookieSameSiteMode\": \"Lax\",\n            \"CheckSessionCookieSameSiteMode\": \"Lax\"\n          }\n        }\n      LOGIN_OPTIONS_INLINE: |\n        {\n          \"AllowRememberLogin\": true\n        }\n      LOGOUT_OPTIONS_INLINE: |\n        {\n          \"AutomaticRedirectAfterSignOut\": true\n        }\n      IDENTITY_RESOURCES_INLINE: |\n        - Name: preferred_username\n          ClaimTypes:\n          - preferred_username\n      USERS_CONFIGURATION_INLINE: |\n        [\n          {\n            \"SubjectId\":\"1\",\n            \"Username\":\"User1\",\n            \"Password\":\"pwd\",\n            \"Claims\": [\n              {\n                \"Type\": \"name\",\n                \"Value\": \"Sam Tailor\",\n                \"ValueType\": \"string\"\n              },\n              {\n                \"Type\": \"email\",\n                \"Value\": \"sam.tailor@gmail.com\",\n                \"ValueType\": \"string\"\n              },\n              {\n                \"Type\": \"preferred_username\",\n                \"Value\": \"sam_tailor\",\n                \"ValueType\": \"string\"\n              },\n            ]\n          }\n        ]\n      CLIENTS_CONFIGURATION_PATH: /tmp/config/clients-config.json\n      ASPNET_SERVICES_OPTIONS_INLINE: |\n        {\n          \"ForwardedHeadersOptions\": {\n            \"ForwardedHeaders\" : \"All\"\n          }\n        }\n    volumes:\n      - .:/tmp/config:ro\n"
  },
  {
    "path": "tests/pyproject.toml",
    "content": "[tool.poetry]\nname = \"tests\"\nversion = \"0.1.0\"\ndescription = \"\"\nauthors = [\"Your Name <you@example.com>\"]\n\n[tool.poetry.dependencies]\npython = \"^3.10\"\npytest = \"^8\"\npsutil = \"^5.9.1\"\npyotp = \"^2.6.0\"\nparamiko = \"^2.11.0\"\nFlask = \"^2.2.1\"\nrequests = \"^2.28.1\"\nflask-sock = \"^0.5.2\"\nwebsocket-client = \"^1.3.3\"\nPyYAML = \"^6.0.2\"\ndeepmerge = \"^2\"\nopenapi-client = { path = \"./api_sdk\", develop = true }\naiohttp = \"^3.11.18\"\ncryptography = \"^46\"\n\n[tool.poetry.dev-dependencies]\nflake8 = \"^5.0.2\"\nblack = \"^24\"\n\n[tool.poetry.group.dev.dependencies]\npytest-asyncio = \"^0.26.0\"\npytest-timeout = \"^2.4.0\"\npyright = \"^1.1.408\"\n\n[tool.pytest.ini_options]\nminversion = \"6.0\"\nfilterwarnings = [\"ignore::urllib3.exceptions.InsecureRequestWarning\"]\n\n[build-system]\nrequires = [\"poetry-core>=1.0.0\"]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "tests/run.sh",
    "content": "#!/bin/sh\nset -e\ncd ..\nrm target/llvm-cov-target/* || true\ncargo llvm-cov clean --workspace\ncargo llvm-cov --no-cfg-coverage-nightly --no-report --workspace --all-features -- --skip agent\ncd tests\nRUST_BACKTRACE=1 ENABLE_COVERAGE=1 poetry run pytest --timeout 300 $@\ncargo llvm-cov report --html\n"
  },
  {
    "path": "tests/ssh-keys/id_ed25519",
    "content": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACAz/wkEtOQGGLxHmd1+hD0hOJYri7j+8Oqex4CMnTr26gAAAKC8ieiIvIno\niAAAAAtzc2gtZWQyNTUxOQAAACAz/wkEtOQGGLxHmd1+hD0hOJYri7j+8Oqex4CMnTr26g\nAAAEBe+1fXsb/WtCsxt6nR5fVqIX9WHQqbpiVxxNTy41IsFDP/CQS05AYYvEeZ3X6EPSE4\nliuLuP7w6p7HgIydOvbqAAAAHGV1Z2VuZUBFdWdlbmVzLU1CUC5mcml0ei5ib3gB\n-----END OPENSSH PRIVATE KEY-----\n"
  },
  {
    "path": "tests/ssh-keys/id_ed25519.pub",
    "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDP/CQS05AYYvEeZ3X6EPSE4liuLuP7w6p7HgIydOvbq\n"
  },
  {
    "path": "tests/ssh-keys/id_rsa",
    "content": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn\nNhAAAAAwEAAQAAAYEAnd9Mz8FnYZm/pXB1J+3Yx8IDsb8vuggGmE/xJZm/H0vuCD4vA/aD\ns3gdqXLjU1/uyD0J/CQPH4GvNqAkJ8AivM2VhnFsG1QJLklkXEHeBFlT81mxO2t0DB4S6Q\nQXgbYC7XtyY6gVqRYuMT+WoanzJcYMv3gSGFr/Yn8WnVYl33zsD8YtLl3Ku71+5kykFC/8\n/LlxOUY73XzTPVuChETvz7KhKrlBiHhOQwjyx2B1JdGQp5hSrBTSmO3+ImIMsClsQw3fP0\nvc7st7L06eOBNTrhPOiQqjyn5MoCfoIRAu6uE7oHyrsLfYiZrJWySz+TdZZNedKfQxWDXB\ncK5+0p8cYyS/U+yGHRrSjSHtl+qqS8QYCTdlteR/EDSuPO7yE63tRpORg6L5kquAupreH6\nrtgO0Hpz/6ZXj9bsAR+Gs+J6MxzrJWeDiAxUAkvg9anYgj3skDFBsfylVe2eey1fQtx0/i\niKNjFyrpqHPAUIyJXa5QTplpZPicKMpa+X38q6gfAAAFmAwAuXkMALl5AAAAB3NzaC1yc2\nEAAAGBAJ3fTM/BZ2GZv6VwdSft2MfCA7G/L7oIBphP8SWZvx9L7gg+LwP2g7N4Haly41Nf\n7sg9CfwkDx+BrzagJCfAIrzNlYZxbBtUCS5JZFxB3gRZU/NZsTtrdAweEukEF4G2Au17cm\nOoFakWLjE/lqGp8yXGDL94Ehha/2J/Fp1WJd987A/GLS5dyru9fuZMpBQv/Py5cTlGO918\n0z1bgoRE78+yoSq5QYh4TkMI8sdgdSXRkKeYUqwU0pjt/iJiDLApbEMN3z9L3O7Ley9Onj\ngTU64TzokKo8p+TKAn6CEQLurhO6B8q7C32ImayVsks/k3WWTXnSn0MVg1wXCuftKfHGMk\nv1Pshh0a0o0h7ZfqqkvEGAk3ZbXkfxA0rjzu8hOt7UaTkYOi+ZKrgLqa3h+q7YDtB6c/+m\nV4/W7AEfhrPiejMc6yVng4gMVAJL4PWp2II97JAxQbH8pVXtnnstX0LcdP4oijYxcq6ahz\nwFCMiV2uUE6ZaWT4nCjKWvl9/KuoHwAAAAMBAAEAAAGAV8re70XRVOBoR/sy24KUI/oLje\nQRCXX/HOKP6uYF98SE2YajJKQI91vbuuiN7EaUBjyTeekfk9jNdCY4FPbvGmmFNl+Ky+O+\nu0PLENb8PRTj75c4TR/jR/3NbFF/NP3fwOr+YNcPPJl+FJsVDE/zTFVHr455GZw5GzArhl\nFq/E5/BAKkC33TCPZHRJDoSeWp3WzOvxgEoJYS7rMd8KpZZfojUBv3ionEk9i9Egzc+KwC\nsoCtsM5fkvX+dmZqQei2UTE7gAQfTdzo7kLrLqeCS5UrVEDEDp94Wf6ZQuEPjKioKfpoHr\nbPiLhcsIH3N7aBpXzXok4dM2U+UgzQW5HfJtgrkUkvlNV3kgtUEw37X9kOhowt/yd6KBT0\nPn5qUtJtBIHKEBpUfyNlJhH9uA/Wift2N7D0TYDVuiAyzT58BK8pS6F55z8ENvvEux/s56\nqZj81FUuGwC9L6xTxya88TOGnEZnVnMFTK8MgbcY1cBZqiKLIgBuusMd5lRCbWaBc5AAAA\nwQCmKQGwQLaffZg2g9fJMyIA7MGDbqMy1Y5DmiGjATGh+oigMY4+ytgvCy8PrBL9NEZCcN\nFMwTUHUgjUP7611DygK+lDHly60bvmhP4JEkI5adxkd13jCXq2dNiazLV7mD2ZghkOkGcl\nhMhd7dmYLL1mzlayEvRKLzvexjmcLS4qFkvDl7mCrUBwsAnr6VbZTIyisz1f3H/u+cbWF5\niSaguuuNL5o1hGCeoCdbAu40e1XOTUQU6kD7GnrV7tATZ8cDMAAADBAMwMsotmUXQkS6fR\nXEiMjse3pVT1hRIcDNNZOeavY/cByFoOv/flo111YL/+aGOT68daLBeMwAYkUtgAFpNZjt\nLpRZKK7/sN2pliWgCU2PWb4is4QBvmWVtIIaCDs7YbwnumZJdyQHoG8W4wQG+RqzBu5VsA\nylinLCbTuZ3I9oOZcLxfJESmTwl3a9oUquN8C4TmcoAS8MRXd7SHndVxS4rvLkHHV5t2bG\nEazYY/2VOUVv8Z3FJc35ZGhzb5vVjLnQAAAMEAxhDpQsXyhoP9CZnkd21rOinYvMDY9ZjC\nAYLJ1k7SWBnUki6ozssnURvPgXUKFdj4xfevNvXQdf6Ctj7b1ghC07gpJ9M4Hq+JJcMPSw\nl9JQpOMCI46nzbLNeAkXhvvpsuMWJO9L4+e+6LZZHHH665e47/dNFgiuIpUQqQaWdqHWGe\n/4z5XrNjRprno01TsMAln8q3aEx3naONapHr9t7WoqT14cuo4NHZVb1oejssDa3iyAajEo\npLZK1nKRsDPQvrAAAAHGV1Z2VuZUBFdWdlbmVzLU1CUC5mcml0ei5ib3gBAgMEBQY=\n-----END OPENSSH PRIVATE KEY-----\n"
  },
  {
    "path": "tests/ssh-keys/id_rsa.pub",
    "content": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCd30zPwWdhmb+lcHUn7djHwgOxvy+6CAaYT/Elmb8fS+4IPi8D9oOzeB2pcuNTX+7IPQn8JA8fga82oCQnwCK8zZWGcWwbVAkuSWRcQd4EWVPzWbE7a3QMHhLpBBeBtgLte3JjqBWpFi4xP5ahqfMlxgy/eBIYWv9ifxadViXffOwPxi0uXcq7vX7mTKQUL/z8uXE5RjvdfNM9W4KERO/PsqEquUGIeE5DCPLHYHUl0ZCnmFKsFNKY7f4iYgywKWxDDd8/S9zuy3svTp44E1OuE86JCqPKfkygJ+ghEC7q4TugfKuwt9iJmslbJLP5N1lk150p9DFYNcFwrn7SnxxjJL9T7IYdGtKNIe2X6qpLxBgJN2W15H8QNK487vITre1Gk5GDovmSq4C6mt4fqu2A7QenP/pleP1uwBH4az4nozHOslZ4OIDFQCS+D1qdiCPeyQMUGx/KVV7Z57LV9C3HT+KIo2MXKumoc8BQjIldrlBOmWlk+Jwoylr5ffyrqB8=\n"
  },
  {
    "path": "tests/ssh-keys/wg/client-ed25519",
    "content": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDujCSXcfts0V3KEZ/vc02DZ0cypyiOHQyNQgT3z35XbAAAAKBv+t0+b/rd\nPgAAAAtzc2gtZWQyNTUxOQAAACDujCSXcfts0V3KEZ/vc02DZ0cypyiOHQyNQgT3z35XbA\nAAAEDDVbCUHpecy/RHT/GFRXSnN+A0uEiI3xYwRuTIpgTXEO6MJJdx+2zRXcoRn+9zTYNn\nRzKnKI4dDI1CBPfPfldsAAAAGmV1Z2VuZUBFdWdlbmVzLU1CUC0yLmxvY2FsAQID\n-----END OPENSSH PRIVATE KEY-----\n"
  },
  {
    "path": "tests/ssh-keys/wg/client-ed25519.pub",
    "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO6MJJdx+2zRXcoRn+9zTYNnRzKnKI4dDI1CBPfPflds eugene@Eugenes-MBP-2.local\n"
  },
  {
    "path": "tests/ssh-keys/wg/client-rsa",
    "content": "-----BEGIN PRIVATE KEY-----\nMIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCvukhj4nGf2l5neOjlolntx+42\r\nyaOz8wmKzc8hRMg8N7GrI4ntGcK7nkgfAuN9a4N+AIaluAjPFeJAtGSGovtLro4AUnFsrJMVN8T5\r\neEhSgE48ko+Limi3JYsC0Vf9YHnVh8t7oioeriSLuyAyTWnykmQIZcYgW1r3bg87Y9qCIceXJQwc\r\njM97czNgckxR4k8a9ja/8kv3spNcNFl7NR7oZ4cyOfbnq5+FJCPohNoviahMtqyVwGVuIXhfTSdo\r\nGRe6mRbbHyK2uAHw2MULUX4E18v72cGDIxuWNqbmdjJozlYKaf+xykVZ6+jv+WrsLziKms9SWcw5\r\n4zNuhR2YZn92/yW+bnBqMjtsZFxgvDBHEM0KIZUuvbV84V/hskwFAo3xyNpy9x6LhlJ17/MkKKmf\r\nGeQ27RjsI8YJFmmakgSHzpg69eqldf4kgsAGr/M9U4ZNE4nrfQnKUY8AN+68pp8oKYFTtrHJ8m0B\r\nY81SGMxynpjNEJPHBv7gYDDUJY8qAhypIP4pxHRohJu6SiE1OEol4ZtQzEzSKKMxxlkberatEvzc\r\nCq6l7d1KS21cmeAdtl7jSMVYTij77B2sDnsmk5PD769wAWJU5T8JaL4bJid45+UKeZz8VQx3e5mi\r\nS/yaGj1uaD6ryhXCZwQh0f9hLvopOGNMEm063qz33lvBys+TDwIDAQABAoICAEO2TxCV/9xtw3Sx\r\nhWR+w5I5ONRJrFe5rZKbrVWPcGyrtT1Rq2L+SygKXJX+gfQhCoDx6PBQUqyhLRZrrFSo1pYaA8Oi\r\nAOy0LtS9MZxDOfL4V61FeCR3x9PSlpcWXYZXt3qNId5Y5Uv/JDvndgeMBugeeoc12Ds9mHbBJQNo\r\nfZkpNQRLlTgnFgfmowRl5nyi7IJiH0SlM5qVZ+zeiyBLnsZEpja3WSl52zTtcRy2nHA25e/xb90g\r\nTrU6Fmz6iNW23YrcVI9IlxK7IpxQmtS6qQlqscIw7Tz/uTCPjI4/OzthTowivhEe9MwqeA6IGCg8\r\nJdhawMplqakgn//VMUs5K6HliyJlUwBAHHjGkOMjq7SCS9tvj6jlLrs/2SeoKyv4Q0lWrZb/QJ+L\r\nxchYHs5aAeHGtO9VLKfHHC0qesw8udJ7yp2g4RiTWEPO9cGufKAUHYDvMf1qnvgbyhmZ0a/ft16z\r\nYb3RO3orcyFcrHNVuutaouafVrIaJS9F7MZNUiUJzMNz4/fCVP+FAJ+Dbyytlz7rq0p86wlBfq0L\r\nk8xK3eoI9cTgb0insgREhwLz3p6KZ4JCnw6rEt2lqEWRkMEAcH1htVtdejp6bq3OMP8PvwViz7Br\r\nDdL3OqG1GaNuHHMNW7Py0RqqokXCGQTAFqyTlXomBHOpWOqRxjkCPPJSMWNhAoIBAQDA4VGvDZEy\r\nh+k8C4ggt128xNZl7rYZMxJcvVg8/mr80RifDaJfr2SY5DDrbFHX18uBaLPioOcjuoWdMaX6+M9F\r\nZwgEA1LvKQg92rG7UQ/DrjbjPMiALsx3yN/P1dFvjxDkEpCGAcAAXh6C4mCrnqseoDV74TQcLsxt\r\nZ4vmL1QifagQbu3xYnyKgO/bPfCiZ4vRyRUCfKv9eoH9dbw4DWyxGXLGNtu77FPXFFeVEoFx7EYG\r\nsZiA1Om37hCgceQ7fGqKgSiwwLtBc+WN5gQ7toQ4OkqyWDkaTcvcNRKmVlf/TiGejZRqny4QjnYV\r\nnQdkBBrekpoXSCghR410pTyktOYfAoIBAQDpPABQL1LRplhRACpcIA4jHYYVx/C7YhqRkO7rlpG+\r\n8hBH3kuE9SFOrPKxWQF8qVw2Jwvs24n5loE5+1Yq29b46mKLkbpEumBj5oLtIPNgM9cBWHlnJwPI\r\nlCpcxVv4Kvsxb+0eaCuxl2DT5zj5d8V6FrpeuQbmpLdKlfwQX2+6w3SqQjaUleHHWvcoqLFcvcml\r\nzVNQrQVt9HvRWsBmg9sHow+CPmF50Z5DfiNPf/vvEegkT3xtcYC6CohIK2/O9R3yjLKZ0Tx9mBse\r\nI2M7mbok9L/5PlsGyNa/+I/aM/eodJqRInaTcs3qGmfA2bjedHEdVuhcMcvnGoM26jSBHFURAoIB\r\nAQCi8Xa1QOvp2WGTJVbR9LaO02cgY8KYlUms6RSTKoetnuOC8ty6owyEETq2mCKoCpjUcWSOT0oV\r\nJ+zauGe1Ft7bjcf6w+gbPPnGb2t4iGmd8R5TaDUl/OMlSqCxDrxI1374fipz2ySd6uUxwxbRxVBg\r\npg2o4r7IFE0FG9XXFyKnpKoHf/8pzf7Sb0yyVahlOr6m8o36NOKDWCxauEzSuZyaHJqWkx+cqXDG\r\noVvABwst9+HMo9nm9Heht896C90418mVyrlaYOeQyt0hvDDVVUJr0erqsZdD/nb7SCbCOO1MNHA4\r\nZvj7/g/HUuK1LZxhxQoB/62Hf6DPRIhfA3yw1FYXAoIBAF2JVarSv8kaiDK7+UEHDgRhM8QKcm4D\r\n0xnr4RWURhEo7QSVjv3cfSYbUB11z5XaKgQBttOf2/6/sEW7mXwIvHcJMMo+gFBN2phV+s30uAYt\r\n5B1DCTUoPWk0mqSn9dFaE3FpLNRT/Kn1RrzU71GFCiqDcOzKEY1wI54C9pruW1WwS1p4wYDndyvH\r\nPHYO6UqDRpp69N3W9eV59ioo1h6G5NF0QKUANYFwYqM4tBqO/k+Lg+kEA6e0rGZwEOW4ndeHECKU\r\n8I+ljTflR4LXuFVPuopVqaPgsQrQgudsXOyqiLkDQnXQN3O8x/4J5vA9oNl+I1sb3oYS5m5hgJwG\r\nY1YgMbECggEAenUW6Szz2klOimJbLnJO08bM0WhGWJzCo0yUCqSV1AEb074ogaS8ByhZ4hnz45IV\r\nKwCyS/pGogxanTSAcnYzb7ty9N68IIcCkSy7fKcmY6+JIFMtaFTyaxfJuzAfxXZ6UidQfi9eqyWL\r\nQlCGVwZoFG7BQsd/xvJ9BinX095JRmKEFKDMktjXCahVi2gHFgYfvFEvSIlclzrIfwGwV4KWdzbA\r\nH3Wt6GmdRdlhgDUB9Q/mDtQ579AWM9OeOhNvpy1E2dUUlS3Trr12DDbLbtG1lEWNut+lFRpL3mCW\r\nkZb3jgOZ60Y9FeI6G9iGSaMs1YPtoWtSg5lqPWBnTGrPZJBMbQ==\r\n\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "tests/ssh-keys/wg/client-rsa.pub",
    "content": "ssh-rsa AAAADHJzYS1zaGEyLTI1NgAAAAMBAAEAAAIBAK+6SGPicZ/aXmd46OWiWe3H7jbJo7PzCYrNzyFEyDw3sasjie0ZwrueSB8C431rg34AhqW4CM8V4kC0ZIai+0uujgBScWyskxU3xPl4SFKATjySj4uKaLcliwLRV/1gedWHy3uiKh6uJIu7IDJNafKSZAhlxiBbWvduDztj2oIhx5clDByMz3tzM2ByTFHiTxr2Nr/yS/eyk1w0WXs1HuhnhzI59uern4UkI+iE2i+JqEy2rJXAZW4heF9NJ2gZF7qZFtsfIra4AfDYxQtRfgTXy/vZwYMjG5Y2puZ2MmjOVgpp/7HKRVnr6O/5auwvOIqaz1JZzDnjM26FHZhmf3b/Jb5ucGoyO2xkXGC8MEcQzQohlS69tXzhX+GyTAUCjfHI2nL3HouGUnXv8yQoqZ8Z5DbtGOwjxgkWaZqSBIfOmDr16qV1/iSCwAav8z1Thk0Tiet9CcpRjwA37rymnygpgVO2scnybQFjzVIYzHKemM0Qk8cG/uBgMNQljyoCHKkg/inEdGiEm7pKITU4SiXhm1DMTNIoozHGWRt6tq0S/NwKrqXt3UpLbVyZ4B22XuNIxVhOKPvsHawOeyaTk8Pvr3ABYlTlPwlovhsmJ3jn5Qp5nPxVDHd7maJL/JoaPW5oPqvKFcJnBCHR/2Eu+ik4Y0wSbTrerPfeW8HKz5MP\n"
  },
  {
    "path": "tests/ssh-keys/wg/host-ed25519",
    "content": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACDBXU9RmJEViQhZZYvaIyEnEMb1X1VCchYGTbyHqbyK6AAAAKBBjcVuQY3F\nbgAAAAtzc2gtZWQyNTUxOQAAACDBXU9RmJEViQhZZYvaIyEnEMb1X1VCchYGTbyHqbyK6A\nAAAEBeZZ1uCofYbG7ypyBBrHGlcggJRbFFFzqGrIxST/B9ksFdT1GYkRWJCFlli9ojIScQ\nxvVfVUJyFgZNvIepvIroAAAAGmV1Z2VuZUBFdWdlbmVzLU1CUC0yLmxvY2FsAQID\n-----END OPENSSH PRIVATE KEY-----\n"
  },
  {
    "path": "tests/ssh-keys/wg/host-ed25519.pub",
    "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMFdT1GYkRWJCFlli9ojIScQxvVfVUJyFgZNvIepvIro eugene@Eugenes-MBP-2.local\n"
  },
  {
    "path": "tests/ssh-keys/wg/host-rsa",
    "content": "-----BEGIN PRIVATE KEY-----\nMIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQCxPfQ6f73tfGFRV93Aby+hCQAP\r\nQlkOo/4BhG2VoPq4gjcyl5XnhV/zUQFTvcYX/AGbZBI4Wgq70d3R8zH8Xz11Vpj9K+KKpzS3ortk\r\nSC5ZN7bOK1eSnlaS7AVrDX2802KIZOuIvxNrgLQeFnMapXPgaSaD/4F6+U48DlmMQ0cgV39rqwmy\r\n1yJjVXBOVmNtZ7Fd2E+5ov3mmR4iLzSs6YYjTd0Atp7EhsKfPv99zLDYQIqeQZJt5kxsWd+0p2Ci\r\nG1BfOTUTyD5x4YMN6JAk0GvzH09SF17M+yhascljTlnZuHgIjVIESAEoNFBoGytVhOkFvtc3MSiZ\r\nFdcb38iAbZkEEjHU1K+KCeAJoGG9rblOKV/xrVMXTLVEKa6gKeetuMJhlqXfbpB6bO6aJuPyLRp7\r\n8Ky0l3kpZrKv2fgMa0sHD14HcPqsEQf8pPhW8oxepyTArTHlU738/9wZ4K8HWt/fKRaXcY81Dz1M\r\nywSNKbvoqOJNvZDABkgeABJYsLxVYv4ReXy8plrJkuVNrlX1H0KKmFCt0F51SCGAOHp4wqWn/g0c\r\nvz5J3P6E2Bnw1P67VbFnBvRHlDFxtbKMzob56TNutNkpDqQYwBgPz2HVQde3Og2Kem0RCxENsiU7\r\nZ78w8NYfGfUiu5qt8jKICiJ48TCciwoSF5/uFwnzM9glEp6BPQIDAQABAoICABnzeUagx4f11dtE\r\nIzrMIiF7oN+bFlEMyox2/a3n3m3qMEzJtxrUA3grxyb3ZVbDq4nl/Wj002zWo0TcooKngJHP9ncz\r\nLWihvMKaeBeMc1oqzIBOsPQjF4f244AzfyfeR3yy/MLj6eMBUDNg6W+LBCFlI/eLD0Pt1s+iRj2W\r\n6DDLFDlegf1b1Il4zAhxoP3hgy27a0jsnYJdrvTQYUUOrXjj1f+xvXixm94XKkTFa1XudUgLT8uk\r\nPvz/rRUqtf0Q72lR21H52B1yfcQpO1m4jpBlaDFxLIyUxZQp7dO1eCBnCw7YKkcS3TXWxbhzKfAh\r\nQBZ679Cr85wef4VxnvjMPe1an/g26brf9o9ralNawdJyakuaOa57CaoiEwk+ZxcZAjpmvS8c0sI0\r\nohsiXxouHbZlv6Hrr/ACnFmRp6y+i9wgkk8850GztILnDs5L3pLSDside3VraefuDJ76Qa8ulXIq\r\n/0c5aoj3JkvJYOSGIDvnQUpE8rcrRs43Az3GRUtYfkCpQlyqORR+aHgMcxZfTLKxhLdbgUE9BJc8\r\nE33xOW0VIsNX2O7RGpDiaFH3dfL9wMORViFR0B/A8q/TKarU7ni+hVgmTyIg7D0NTnnZNaOSloBr\r\nA50iFJxa5YqmpDduaki5WlLzBFauDH5UaeNeAPrT1fYWksrl5IGdOjDxkEE9AoIBAQDUz4jlGW0s\r\nhAmafSrtwbVBPBFZ122AuyplibLlHgADsD6Uu3tUKgVd22nCV8wzlpxL9GPG80S5aJPPI6gpl2XH\r\nvUV2yWuJLsW5324hdKjxH5mJjWZtA0CpIZ+PdNc3LLolFLgew5HMt1sHZeqFRqxbZLGBmNuXyOme\r\nk1SCTVmMuTK8wzWVzDhc2g+u4NL1eRccmS18bGKGA+oNnY/IRdjdPw5gNeBg/6ZVimXj91d0WiFi\r\n6Pr0Bop0g0bpAoWk6s/XskBVQAYcCmNZMI+ll05RgARKYK+Donn/zu/5jGYXQ+9jAv78q796nT2J\r\ndRIPAh/azTxu+yE1byaC01iLGTVDAoIBAQDVNnfBHRF2mWLwfpPponXISupn2WApgjX7P8HS9WjN\r\nbb/X9gbjNJXIgF+XqpwOxaQFolCHw9uyv7+73PHhDiaSqdw5NkvFtfJkacWlJblybUVVGljaGgpp\r\nKFlRqcX8knFwNpDXwnoMttST0eoWZnj5jeWhQkc/8XceN9NPY4fxAwXceMWBAW03p47vMpxC6FGN\r\nEt7KlBB5coQWkVZyxjq3n69FouHAQZWEjBmgBpGf16HtvSGIO91hr3PXX5oktvRejN25WQiwLG0O\r\n/DHnNvQcovb9QpjZ3fnBfkxQmzqLsdCR/FTsYtvMqnR2OzR/zI3UeRPAphzfWBAG2W6+Esd/AoIB\r\nAQC90GqjJd254f+K22/p52hbSk+TmdIjC05SiNKXB/4tTAtVsC/drylgQO+BF7ycmw7HtLE2aA95\r\nbKzCCmTYzCBNWyXVQOz4zE4ybvaVQq/Zej0BcqzUOR14ffQLCcVYgj16C5P6ZKfsN/Mqkx3uSE49\r\nqn+lP4lGRj8SYQj0vDdOjHWT5m4qMaBoOVvZuNCRgLM7n+jxXN8398/Q2yO/F4XKOY8CA6wh+IUN\r\nMUeWYSyRLD8xMOt9s0PVjq418TjxEzvVgTlekJ+ibSWWDPljUqTZjtzE1p5WRBqbL6HeLPt2bvLb\r\nlnWHO02r+QpFS7WSy2tMRtlLiBVjysNH12jXkOFvAoIBAEyGSiETr8rjbrFmnOwEFUYYLV2slWkQ\r\nhRNyZLy0vDLPK0X11a8Clqfp+2VSJMTghuhGw6SW1WmojMZ+nInsLEgDkzktlbCWhzMnC3skuRSq\r\nx3GuDSnqosXvZ296AcePQAvIaeAmuuuJS27qrpvvl4fqN/rS8QOwRNKhssQRsx77uMTSzABrZKnP\r\nB+wuPAt/mpWJqlEHJ4qPYX1AGMkFANobBCt4NJJud52lMyVOdkHqgQH1Ge3tnp2K/YbVl1uKFtdA\r\ns+vsWsPwjgwM1FRqUt9cVk2782Ru2U9rZzSfIjo1Tei3qjtVmBIzM62jvkoIPvd9pWtFs6Mt1kK/\r\nE5JA5z0CggEBAKgsIb3x7mXJXelmyeD3m3KdzIsIqHZ1yo3YUPs6O/2UKwmPMEAcgfBbiIL/Z0Tw\r\nGqytCKR8ghKxj/CLHqZne9P+hd0vJrJfJGuita6JC9HI8EVd024FV+QAjZjQHdSUe3NY4h+J9U0h\r\nf0r/LIyoWGGKKaPLVxJF5EJa46cixhSp71w4fhlIG/FtTNXVGzkJeh4jrLlKU84RxpprUoS9wwKS\r\ngidavaqws6Ks8oVf0oJaqeMk8RrlEeAR1OyXtaxdAdYnHsMKLLoNrEKUAHdBexpJ9fhqlQhZWkT6\r\nxS6VZKFUtFtzffTY0HE+R+ESSmiUSl3mB6gsfiXlu0Q9kxjw3wU=\r\n\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "tests/test_api_auth.py",
    "content": "import contextlib\nfrom dataclasses import dataclass\nfrom typing import Callable, Dict, Optional, Set\nfrom json import load\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\nfrom uuid import uuid4\n\nimport pytest\nimport requests\n\nfrom .api_client import sdk, admin_client as new_admin_client\nfrom .conftest import WarpgateProcess\nfrom .test_http_common import *  # noqa\n\n\n@dataclass\nclass AdminApiTestCase:\n    id: str\n    permission: Optional[str]\n    call: Callable[[sdk.DefaultApi, Dict[str, object]], sdk.ApiResponse]\n    expected_statuses: Set[int]\n\n\n@contextlib.contextmanager\ndef assert_401():\n    with pytest.raises(sdk.ApiException) as e:\n        yield\n    assert e.value.status == 401\n\n\ndef make_limited_admin_role_payload(**overrides):\n    return {\n        \"name\": overrides.get(\"name\", f\"limited-{uuid4()}\"),\n        \"description\": \"limited permissions\",\n        \"targets_create\": False,\n        \"targets_edit\": False,\n        \"targets_delete\": False,\n        \"users_create\": False,\n        \"users_edit\": False,\n        \"users_delete\": False,\n        \"access_roles_create\": False,\n        \"access_roles_edit\": False,\n        \"access_roles_delete\": False,\n        \"access_roles_assign\": False,\n        \"sessions_view\": False,\n        \"sessions_terminate\": False,\n        \"recordings_view\": False,\n        \"tickets_create\": False,\n        \"tickets_delete\": False,\n        \"config_edit\": False,\n        \"admin_roles_manage\": False,\n        **overrides,\n    }\n\n\nADMIN_API_TEST_CASES: list[AdminApiTestCase] = [\n    AdminApiTestCase(\n        id=\"get_sessions\",\n        permission=\"sessions_view\",\n        call=lambda api, r: api.get_sessions_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_session\",\n        permission=\"sessions_view\",\n        call=lambda api, r: api.get_session_with_http_info(r[\"session_id\"]),\n        expected_statuses={200, 404},\n    ),\n    AdminApiTestCase(\n        id=\"get_session_recordings\",\n        permission=\"recordings_view\",\n        call=lambda api, r: api.get_session_recordings_with_http_info(r[\"session_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"close_session\",\n        permission=\"sessions_terminate\",\n        call=lambda api, r: api.close_session_with_http_info(r[\"session_id\"]),\n        expected_statuses={200, 404},\n    ),\n    AdminApiTestCase(\n        id=\"close_all_sessions\",\n        permission=\"sessions_terminate\",\n        call=lambda api, r: api.close_all_sessions_with_http_info(),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"get_recording\",\n        permission=\"recordings_view\",\n        call=lambda api, r: api.get_recording_with_http_info(r[\"recording_id\"]),\n        expected_statuses={200, 404},\n    ),\n    AdminApiTestCase(\n        id=\"get_kubernetes_recording\",\n        permission=\"recordings_view\",\n        call=lambda api, r: api.get_kubernetes_recording_with_http_info(\n            r[\"recording_id\"]\n        ),\n        expected_statuses={200, 404},\n    ),\n    AdminApiTestCase(\n        id=\"get_roles\",\n        permission=None,\n        call=lambda api, r: api.get_roles_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_role\",\n        permission=\"access_roles_create\",\n        call=lambda api, r: api.create_role_with_http_info(\n            sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"get_role\",\n        permission=None,\n        call=lambda api, r: api.get_role_with_http_info(r[\"role_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"update_role\",\n        permission=\"access_roles_edit\",\n        call=lambda api, r: api.update_role_with_http_info(\n            r[\"role_id\"],\n            sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_role_targets\",\n        permission=None,\n        call=lambda api, r: api.get_role_targets_with_http_info(r[\"role_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_role_users\",\n        permission=None,\n        call=lambda api, r: api.get_role_users_with_http_info(r[\"role_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_admin_roles\",\n        permission=None,\n        call=lambda api, r: api.get_admin_roles_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_admin_role\",\n        permission=\"admin_roles_manage\",\n        call=lambda api, r: api.create_admin_role_with_http_info(\n            sdk.AdminRoleDataRequest(\n                **make_limited_admin_role_payload(name=f\"admin-role-{uuid4()}\")\n            )\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"get_admin_role\",\n        permission=None,\n        call=lambda api, r: api.get_admin_role_with_http_info(r[\"admin_role_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"update_admin_role\",\n        permission=\"admin_roles_manage\",\n        call=lambda api, r: api.update_admin_role_with_http_info(\n            r[\"admin_role_id\"],\n            sdk.AdminRoleDataRequest(\n                **make_limited_admin_role_payload(name=f\"admin-role-{uuid4()}\")\n            ),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_admin_role_users\",\n        permission=None,\n        call=lambda api, r: api.get_admin_role_users_with_http_info(r[\"admin_role_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_tickets\",\n        permission=None,\n        call=lambda api, r: api.get_tickets_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_ticket\",\n        permission=\"tickets_create\",\n        call=lambda api, r: api.create_ticket_with_http_info(\n            sdk.CreateTicketRequest(\n                username=r[\"username\"],\n                target_name=r[\"target_name\"],\n            ),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"delete_ticket\",\n        permission=\"tickets_delete\",\n        call=lambda api, r: api.delete_ticket_with_http_info(r[\"ticket_id\"]),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"add_ssh_known_host\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.add_ssh_known_host_with_http_info(\n            sdk.AddSshKnownHostRequest(\n                host=\"127.0.0.1\",\n                port=22,\n                key_type=\"ecdsa-sha2-nistp256\",\n                key_base64=\"AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKL5C+OCN2hAbPoR+mwG4M402Z0XVDOuV5k7n6zCRIMsgnYiyz6a61Zcw/RRHoQAb7ndqUyk8eAi9gjPEiGq2d0=\",\n            ),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_ssh_known_hosts\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.get_ssh_known_hosts_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"delete_ssh_known_host\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.delete_ssh_known_host_with_http_info(\n            r[\"ssh_known_host_id\"]\n        ),\n        expected_statuses={204, 404},\n    ),\n    AdminApiTestCase(\n        id=\"get_ssh_own_keys\",\n        permission=None,\n        call=lambda api, r: api.get_ssh_own_keys_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_logs\",\n        permission=None,\n        call=lambda api, r: api.get_logs_with_http_info(sdk.GetLogsRequest(search=\"\")),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_targets\",\n        permission=None,\n        call=lambda api, r: api.get_targets_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_target\",\n        permission=\"targets_create\",\n        call=lambda api, r: api.create_target_with_http_info(\n            sdk.TargetDataRequest(\n                name=f\"target-{uuid4()}\",\n                options=sdk.TargetOptions(\n                    sdk.TargetOptionsTargetSSHOptions(\n                        kind=\"Ssh\",\n                        host=\"127.0.0.1\",\n                        port=22,\n                        username=\"user\",\n                        auth=sdk.SSHTargetAuth(\n                            sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind=\"PublicKey\")\n                        ),\n                    )\n                ),\n            ),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"get_target\",\n        permission=None,\n        call=lambda api, r: api.get_target_with_http_info(r[\"target_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"update_target\",\n        permission=\"targets_edit\",\n        call=lambda api, r: api.update_target_with_http_info(\n            r[\"target_id\"],\n            sdk.TargetDataRequest(\n                name=f\"target-{uuid4()}\",\n                options=sdk.TargetOptions(\n                    sdk.TargetOptionsTargetSSHOptions(\n                        kind=\"Ssh\",\n                        host=\"127.0.0.1\",\n                        port=22,\n                        username=\"user\",\n                        auth=sdk.SSHTargetAuth(\n                            sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind=\"PublicKey\")\n                        ),\n                    )\n                ),\n            ),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_ssh_target_known_ssh_host_keys\",\n        permission=\"targets_edit\",\n        call=lambda api, r: api.get_ssh_target_known_ssh_host_keys_with_http_info(\n            r[\"target_id\"]\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_target_roles\",\n        permission=None,\n        call=lambda api, r: api.get_target_roles_with_http_info(r[\"target_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"add_target_role\",\n        permission=\"access_roles_assign\",\n        call=lambda api, r: api.add_target_role_with_http_info(\n            r[\"target_id\"], r[\"role_id\"]\n        ),\n        expected_statuses={201, 409},\n    ),\n    AdminApiTestCase(\n        id=\"delete_target_role\",\n        permission=\"access_roles_assign\",\n        call=lambda api, r: api.delete_target_role_with_http_info(\n            r[\"target_id\"], r[\"role_id\"]\n        ),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"list_target_groups\",\n        permission=None,\n        call=lambda api, r: api.list_target_groups_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_target_group\",\n        permission=\"targets_create\",\n        call=lambda api, r: api.create_target_group_with_http_info(\n            sdk.TargetGroupDataRequest(name=f\"group-{uuid4()}\"),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"get_target_group\",\n        permission=None,\n        call=lambda api, r: api.get_target_group_with_http_info(r[\"target_group_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"update_target_group\",\n        permission=\"targets_edit\",\n        call=lambda api, r: api.update_target_group_with_http_info(\n            r[\"target_group_id\"],\n            sdk.TargetGroupDataRequest(name=f\"group-{uuid4()}\"),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"delete_target_group\",\n        permission=\"targets_delete\",\n        call=lambda api, r: api.delete_target_group_with_http_info(\n            r[\"target_group_id\"]\n        ),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"get_users\",\n        permission=None,\n        call=lambda api, r: api.get_users_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_user\",\n        permission=\"users_create\",\n        call=lambda api, r: api.create_user_with_http_info(\n            sdk.CreateUserRequest(username=f\"user-{uuid4()}\"),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"get_user\",\n        permission=None,\n        call=lambda api, r: api.get_user_with_http_info(r[\"user_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"update_user\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.update_user_with_http_info(\n            r[\"user_id\"],\n            sdk.UserDataRequest(username=f\"user-{uuid4()}\"),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"unlink_user_from_ldap\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.unlink_user_from_ldap_with_http_info(r[\"user_id\"]),\n        expected_statuses={200, 400},\n    ),\n    AdminApiTestCase(\n        id=\"auto_link_user_to_ldap\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.auto_link_user_to_ldap_with_http_info(r[\"user_id\"]),\n        expected_statuses={200, 400},\n    ),\n    AdminApiTestCase(\n        id=\"get_user_roles\",\n        permission=None,\n        call=lambda api, r: api.get_user_roles_with_http_info(r[\"user_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"add_user_role\",\n        permission=\"access_roles_assign\",\n        call=lambda api, r: api.add_user_role_with_http_info(\n            r[\"user_id\"], r[\"role_id\"]\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"delete_user_role\",\n        permission=\"access_roles_assign\",\n        call=lambda api, r: api.delete_user_role_with_http_info(\n            r[\"user_id\"], r[\"role_id\"]\n        ),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"get_user_admin_roles\",\n        permission=None,\n        call=lambda api, r: api.get_user_admin_roles_with_http_info(r[\"user_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"add_user_admin_role\",\n        permission=\"admin_roles_manage\",\n        call=lambda api, r: api.add_user_admin_role_with_http_info(\n            r[\"user_id\"], r[\"admin_role_id\"]\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"delete_user_admin_role\",\n        permission=\"admin_roles_manage\",\n        call=lambda api, r: api.delete_user_admin_role_with_http_info(\n            r[\"user_id\"], r[\"admin_role_id\"]\n        ),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"get_password_credentials\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.get_password_credentials_with_http_info(r[\"user_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_password_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.create_password_credential_with_http_info(\n            r[\"user_id\"],\n            sdk.NewPasswordCredential(password=\"123\"),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"delete_password_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.delete_password_credential_with_http_info(\n            r[\"user_id\"], r[\"password_id\"]\n        ),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"get_sso_credentials\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.get_sso_credentials_with_http_info(r[\"user_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_sso_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.create_sso_credential_with_http_info(\n            r[\"user_id\"],\n            sdk.NewSsoCredential(email=\"test@example.com\", provider=\"test\"),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"update_sso_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.update_sso_credential_with_http_info(\n            r[\"user_id\"],\n            r[\"sso_id\"],\n            sdk.NewSsoCredential(email=\"test@example.com\", provider=\"test\"),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"delete_sso_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.delete_sso_credential_with_http_info(\n            r[\"user_id\"], r[\"sso_id\"]\n        ),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"get_public_key_credentials\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.get_public_key_credentials_with_http_info(r[\"user_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_public_key_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.create_public_key_credential_with_http_info(\n            r[\"user_id\"],\n            sdk.NewPublicKeyCredential(\n                label=\"key\",\n                openssh_public_key=\"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKL5C+OCN2hAbPoR+mwG4M402Z0XVDOuV5k7n6zCRIMsgnYiyz6a61Zcw/RRHoQAb7ndqUyk8eAi9gjPEiGq2d0=\",\n            ),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"update_public_key_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.update_public_key_credential_with_http_info(\n            r[\"user_id\"],\n            r[\"public_key_id\"],\n            sdk.NewPublicKeyCredential(\n                label=\"key\",\n                openssh_public_key=\"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKL5C+OCN2hAbPoR+mwG4M402Z0XVDOuV5k7n6zCRIMsgnYiyz6a61Zcw/RRHoQAb7ndqUyk8eAi9gjPEiGq2d0=\",\n            ),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"delete_public_key_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.delete_public_key_credential_with_http_info(\n            r[\"user_id\"], r[\"public_key_id\"]\n        ),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"get_otp_credentials\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.get_otp_credentials_with_http_info(r[\"user_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_otp_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.create_otp_credential_with_http_info(\n            r[\"user_id\"],\n            sdk.NewOtpCredential(name=\"otp-1\", secret_key=[1, 2, 3]),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"delete_otp_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.delete_otp_credential_with_http_info(\n            r[\"user_id\"], r[\"otp_id\"]\n        ),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"get_ldap_servers\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.get_ldap_servers_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"create_ldap_server\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.create_ldap_server_with_http_info(\n            sdk.CreateLdapServerRequest(\n                name=f\"ldap-{uuid4()}\",\n                host=\"127.0.0.1\",\n                bind_dn=\"cn=admin,dc=example,dc=org\",\n                bind_password=\"pass\",\n            ),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"test_ldap_server_connection\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.test_ldap_server_connection_with_http_info(\n            sdk.TestLdapServerRequest(\n                host=\"127.0.0.1\",\n                port=389,\n                bind_dn=\"cn=admin,dc=example,dc=org\",\n                bind_password=\"pass\",\n                tls_mode=sdk.TlsMode.DISABLED,\n                tls_verify=False,\n            ),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_ldap_server\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.get_ldap_server_with_http_info(r[\"ldap_server_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"update_ldap_server\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.update_ldap_server_with_http_info(\n            r[\"ldap_server_id\"],\n            sdk.UpdateLdapServerRequest(\n                name=f\"ldap-{uuid4()}\",\n                host=\"127.0.0.1\",\n                bind_dn=\"cn=admin,dc=example,dc=org\",\n                bind_password=\"pass\",\n                auto_link_sso_users=False,\n                description=\"\",\n                enabled=True,\n                port=123,\n                ssh_key_attribute=\"asd\",\n                tls_mode=sdk.TlsMode.DISABLED,\n                tls_verify=False,\n                user_filter=\"(&(objectClass=person)(uid={0}))\",\n                username_attribute=sdk.LdapUsernameAttribute.UID,\n                uuid_attribute=\"uid\",\n            ),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_ldap_users\",\n        permission=\"users_create\",\n        call=lambda api, r: api.get_ldap_users_with_http_info(r[\"ldap_server_id\"]),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"import_ldap_users\",\n        permission=\"users_create\",\n        call=lambda api, r: api.import_ldap_users_with_http_info(\n            r[\"ldap_server_id\"],\n            import_ldap_users_request=sdk.ImportLdapUsersRequest(dns=[]),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_parameters\",\n        permission=None,\n        call=lambda api, r: api.get_parameters_with_http_info(),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"update_parameters\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.update_parameters_with_http_info(\n            sdk.ParameterUpdate(\n                allow_own_credential_management=True,\n                minimize_password_login=False,\n                rate_limit_bytes_per_second=None,\n                ssh_client_auth_keyboard_interactive=True,\n                ssh_client_auth_password=True,\n                ssh_client_auth_publickey=True,\n            ),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"check_ssh_host_key\",\n        permission=\"targets_edit\",\n        call=lambda api, r: api.check_ssh_host_key_with_http_info(\n            sdk.CheckSshHostKeyRequest(host=\"127.0.0.1\", port=22),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"get_certificate_credentials\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.get_certificate_credentials_with_http_info(\n            r[\"user_id\"]\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"issue_certificate_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.issue_certificate_credential_with_http_info(\n            r[\"user_id\"],\n            sdk.IssueCertificateCredentialRequest(\n                label=\"test\",\n                public_key_pem=\"-----BEGIN PUBLIC KEY-----\\nMFswDQYJKoZIhvcNAQEBBQADSgAwRwJAXWRPQyGlEY+SXz8Uslhe+MLjTgWd8lf/\\nnA0hgCm9JFKC1tq1S73cQ9naClNXsMqY7pwPt1bSY8jYRqHHbdoUvwIDAQAB\\n-----END PUBLIC KEY-----\",\n            ),\n        ),\n        expected_statuses={201},\n    ),\n    AdminApiTestCase(\n        id=\"update_certificate_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.update_certificate_credential_with_http_info(\n            r[\"user_id\"],\n            r[\"certificate_id\"],\n            sdk.UpdateCertificateCredential(label=\"test\"),\n        ),\n        expected_statuses={200},\n    ),\n    AdminApiTestCase(\n        id=\"revoke_certificate_credential\",\n        permission=\"users_edit\",\n        call=lambda api, r: api.revoke_certificate_credential_with_http_info(\n            r[\"user_id\"], r[\"certificate_id\"]\n        ),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"delete_role\",\n        permission=\"access_roles_delete\",\n        call=lambda api, r: api.delete_role_with_http_info(r[\"role_id\"]),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"delete_target\",\n        permission=\"targets_delete\",\n        call=lambda api, r: api.delete_target_with_http_info(r[\"target_id\"]),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"delete_user\",\n        permission=\"users_delete\",\n        call=lambda api, r: api.delete_user_with_http_info(r[\"user_id\"]),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"delete_ldap_server\",\n        permission=\"config_edit\",\n        call=lambda api, r: api.delete_ldap_server_with_http_info(r[\"ldap_server_id\"]),\n        expected_statuses={204},\n    ),\n    AdminApiTestCase(\n        id=\"delete_admin_role\",\n        permission=\"admin_roles_manage\",\n        call=lambda api, r: api.delete_admin_role_with_http_info(r[\"admin_role_id\"]),\n        expected_statuses={204},\n    ),\n]\n\n\ndef _verify_all_openapi_ops_are_covered():\n    schema = load(\n        open(\n            Path(__file__).resolve().parents[1]\n            / \"warpgate-web\"\n            / \"src\"\n            / \"admin\"\n            / \"lib\"\n            / \"openapi-schema.json\"\n        )\n    )\n    schema_ops = {\n        op.get(\"operationId\")\n        for methods in schema.get(\"paths\", {}).values()\n        for op in methods.values()\n        if op.get(\"operationId\")\n    }\n    missing = schema_ops - {c.id for c in ADMIN_API_TEST_CASES}\n    assert not missing, f\"Missing test cases for operations: {sorted(missing)}\"\n\n\ndef _create_admin_role(admin_api: sdk.DefaultApi, payload: dict) -> sdk.AdminRole:\n    return admin_api.create_admin_role(sdk.AdminRoleDataRequest(**payload))\n\n\ndef _create_user_with_role(admin_api: sdk.DefaultApi, role_id: str | None):\n    user = admin_api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n    admin_api.create_password_credential(\n        user.id, sdk.NewPasswordCredential(password=\"123\")\n    )\n    if role_id:\n        admin_api.add_user_admin_role(user.id, role_id)\n    return user\n\n\ndef _create_user_api_token(\n    base_url: str, username: str, password: str, label: str = \"test\"\n) -> str:\n    session = requests.Session()\n    session.verify = False\n\n    # Log in to get an authenticated session cookie.\n    resp = session.post(\n        f\"{base_url}/@warpgate/api/auth/login\",\n        json={\"username\": username, \"password\": password},\n    )\n    resp.raise_for_status()\n\n    expiry = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()\n    token_resp = session.post(\n        f\"{base_url}/@warpgate/api/profile/api-tokens\",\n        json={\"label\": label, \"expiry\": expiry},\n    )\n    token_resp.raise_for_status()\n\n    return token_resp.json()[\"secret\"]\n\n\ndef test_all_openapi_admin_operations_permission_enforcement(\n    shared_wg: WarpgateProcess, admin_client: sdk.DefaultApi\n):\n    _verify_all_openapi_ops_are_covered()\n\n    url = f\"https://localhost:{shared_wg.http_port}\"\n\n    resources: Dict[str, object] = {}\n    resources[\"role_id\"] = admin_client.create_role(\n        sdk.RoleDataRequest(name=f\"role-{uuid4()}\")\n    ).id\n    resources[\"admin_role_id\"] = _create_admin_role(\n        admin_client,\n        make_limited_admin_role_payload(name=f\"admin-role-{uuid4()}\"),\n    ).id\n    resources[\"target_group_id\"] = admin_client.create_target_group(\n        sdk.TargetGroupDataRequest(\n            name=f\"group-{uuid4()}\", description=\"\", color=sdk.BootstrapThemeColor.INFO\n        )\n    ).id\n    user = admin_client.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n    resources[\"user_id\"] = user.id\n    resources[\"username\"] = user.username\n\n    # fake IDs here\n    resources[\"session_id\"] = str(uuid4())\n    resources[\"recording_id\"] = str(uuid4())\n    resources[\"ssh_known_host_id\"] = str(uuid4())\n\n    target = admin_client.create_target(\n        sdk.TargetDataRequest(\n            name=f\"target-{uuid4()}\",\n            options=sdk.TargetOptions(\n                sdk.TargetOptionsTargetSSHOptions(\n                    kind=\"Ssh\",\n                    host=\"127.0.0.1\",\n                    port=22,\n                    username=\"user\",\n                    auth=sdk.SSHTargetAuth(\n                        sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind=\"PublicKey\")\n                    ),\n                )\n            ),\n        )\n    )\n    resources[\"target_id\"] = target.id\n    resources[\"target_name\"] = target.name\n\n    ticket = admin_client.create_ticket(\n        sdk.CreateTicketRequest(username=\"test\", target_name=resources[\"target_name\"])\n    )\n    resources[\"ticket_id\"] = ticket.ticket.id\n\n    pw = admin_client.create_password_credential(\n        resources[\"user_id\"], sdk.NewPasswordCredential(password=\"123\")\n    )\n    resources[\"password_id\"] = pw.id\n    sso = admin_client.create_sso_credential(\n        resources[\"user_id\"],\n        sdk.NewSsoCredential(email=\"test@example.com\", provider=\"test\"),\n    )\n    resources[\"sso_id\"] = sso.id\n    public_key = admin_client.create_public_key_credential(\n        resources[\"user_id\"],\n        sdk.NewPublicKeyCredential(\n            label=\"key\",\n            openssh_public_key=\"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKL5C+OCN2hAbPoR+mwG4M402Z0XVDOuV5k7n6zCRIMsgnYiyz6a61Zcw/RRHoQAb7ndqUyk8eAi9gjPEiGq2d0=\",\n        ),\n    )\n    resources[\"public_key_id\"] = public_key.id\n    otp = admin_client.create_otp_credential(\n        resources[\"user_id\"], sdk.NewOtpCredential(name=\"otp-1\", secret_key=[1, 2, 3])\n    )\n    resources[\"otp_id\"] = otp.id\n    cert = admin_client.issue_certificate_credential(\n        resources[\"user_id\"],\n        sdk.IssueCertificateCredentialRequest(\n            label=\"test\",\n            public_key_pem=\"-----BEGIN PUBLIC KEY-----\\nMFswDQYJKoZIhvcNAQEBBQADSgAwRwJAXWRPQyGlEY+SXz8Uslhe+MLjTgWd8lf/\\nnA0hgCm9JFKC1tq1S73cQ9naClNXsMqY7pwPt1bSY8jYRqHHbdoUvwIDAQAB\\n-----END PUBLIC KEY-----\",\n        ),\n    )\n    resources[\"certificate_id\"] = cert.credential.id\n    ldap = admin_client.create_ldap_server(\n        sdk.CreateLdapServerRequest(\n            name=f\"ldap-{uuid4()}\",\n            host=\"127.0.0.1\",\n            bind_dn=\"cn=admin,dc=example,dc=org\",\n            bind_password=\"pass\",\n        )\n    )\n    resources[\"ldap_server_id\"] = ldap.id\n\n    for case in ADMIN_API_TEST_CASES:\n        # Positive case: role has required permission (or any admin if None).\n        allow_payload = make_limited_admin_role_payload(\n            **({case.permission: True} if case.permission else {})\n        )\n        allowed_role = _create_admin_role(admin_client, allow_payload)\n        allowed_user = _create_user_with_role(admin_client, allowed_role.id)\n        token = _create_user_api_token(url, allowed_user.username, \"123\")\n        with new_admin_client(url, token) as allowed_api:\n            try:\n                response = case.call(allowed_api, resources)\n                (status, body) = response.status_code, response.data\n            except sdk.ApiException as e:\n                (status, body) = e.status, e.body\n            assert status in case.expected_statuses, (\n                f\"{case.id} expected {case.expected_statuses} but got {status}: {body}\"\n            )\n\n            # Negative case: permission missing should be rejected.\n            if case.permission:\n                denied_role = _create_admin_role(\n                    admin_client,\n                    {\n                        k: not v if isinstance(v, bool) else v\n                        for k, v in allow_payload.items()\n                    },\n                )\n                denied_user = _create_user_with_role(admin_client, denied_role.id)\n            else:\n                denied_user = _create_user_with_role(admin_client, None)\n\n            denied_token = _create_user_api_token(url, denied_user.username, \"123\")\n\n            with new_admin_client(\n                f\"https://localhost:{shared_wg.http_port}\", denied_token\n            ) as denied_api:\n                try:\n                    response = case.call(denied_api, resources)\n                    (status, body) = response.status_code, response.data\n                except sdk.ApiException as e:\n                    (status, body) = e.status, e.body\n                assert status in {401, 403}, (\n                    f\"{case.id} should be forbidden without {case.permission}, got {status}: {body}\"\n                )\n"
  },
  {
    "path": "tests/test_http_basic.py",
    "content": "from urllib.parse import unquote\nfrom uuid import uuid4\nimport requests\n\nfrom tests.conftest import WarpgateProcess\n\nfrom .api_client import admin_client, sdk\nfrom .test_http_common import *  # noqa\n\n\nclass Test:\n    def test_basic(\n        self,\n        echo_server_port,\n        shared_wg: WarpgateProcess,\n    ):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"echo-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetHTTPOptions(\n                            kind=\"Http\",\n                            url=f\"http://user:pass@localhost:{echo_server_port}\",\n                            tls=sdk.Tls(\n                                mode=sdk.TlsMode.DISABLED,\n                                verify=False,\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(target.id, role.id)\n\n        session = requests.Session()\n        session.verify = False\n\n        response = session.get(\n            f\"{url}/?warpgate-target={target.name}\", allow_redirects=False\n        )\n        assert response.status_code == 307\n        redirect = response.headers[\"location\"]\n        print(unquote(redirect))\n        assert (\n            unquote(redirect)\n            == f\"/@warpgate#/login?next=/?warpgate-target={target.name}\"\n        )\n\n        response = session.get(f\"{url}/@warpgate/api/info\").json()\n        assert response[\"username\"] is None\n\n        response = session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n        )\n        assert response.status_code == 201\n\n        response = session.get(f\"{url}/@warpgate/api/info\").json()\n        assert response[\"username\"] == user.username\n\n        response = session.get(\n            f\"{url}/some/path?a=b&warpgate-target={target.name}&c=d\",\n            allow_redirects=False,\n        )\n        assert response.status_code == 200\n        assert response.json()[\"method\"] == \"GET\"\n        assert response.json()[\"path\"] == \"/some/path\"\n        assert response.json()[\"args\"][\"a\"] == \"b\"\n        assert response.json()[\"args\"][\"c\"] == \"d\"\n        assert ['Authorization', 'Basic dXNlcjpwYXNz'] in response.json()[\"headers\"]\n"
  },
  {
    "path": "tests/test_http_common.py",
    "content": "import gzip\nimport pytest\nimport threading\n\nfrom .util import alloc_port\n\n\n@pytest.fixture(scope=\"session\")\ndef echo_server_port():\n    from flask import Flask, request, jsonify, redirect, make_response\n    from flask_sock import Sock\n\n    app = Flask(__name__)\n    sock = Sock(app)\n\n    @app.route(\"/set-cookie\")\n    def set_cookie():\n        response = jsonify({})\n        response.set_cookie(\"cookie\", \"value\")\n        return response\n\n    @app.route(\"/redirect/<path:url>\")\n    def r(url):\n        return redirect(url)\n\n    @app.route(\"/\", defaults={\"path\": \"\"})\n    @app.route(\"/<path:path>\")\n    def echo(path):\n        return jsonify(\n            {\n                \"method\": request.method,\n                \"args\": request.args,\n                \"path\": request.path,\n                \"headers\": request.headers.to_wsgi_list(),\n            }\n        )\n\n    @app.route(\"/gzip-response\")\n    def gzip_response():\n        content = gzip.compress(b'response', 5)\n        response = make_response(content)\n        response.headers['Content-Length'] = len(content)\n        response.headers['Content-Encoding'] = 'gzip'\n        return response\n\n\n    @sock.route(\"/socket\")\n    def ws_echo(ws):\n        while True:\n            data = ws.receive()\n            ws.send(data)\n\n    port = alloc_port()\n\n    def runner():\n        app.run(port=port, load_dotenv=False)\n\n    thread = threading.Thread(target=runner, daemon=True)\n    thread.start()\n\n    yield port\n"
  },
  {
    "path": "tests/test_http_proto.py",
    "content": "import requests\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess\nfrom .test_http_common import *  # noqa\n\n\nclass TestHTTPProto:\n    @pytest.fixture(scope=\"session\")\n    def setup(self, echo_server_port, shared_wg):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"echo-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetHTTPOptions(\n                            kind=\"Http\",\n                            url=f\"http://localhost:{echo_server_port}\",\n                            tls=sdk.Tls(\n                                mode=sdk.TlsMode.DISABLED,\n                                verify=False,\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(echo_target.id, role.id)\n\n        yield url, user, echo_target\n\n    def test_cookies(\n        self,\n        setup,\n        shared_wg: WarpgateProcess,\n    ):\n        url, user, echo_target = setup\n\n        session = requests.Session()\n        session.verify = False\n        headers = {\"Host\": f\"localhost:{shared_wg.http_port}\"}\n\n        session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n            headers=headers,\n        )\n\n        session.get(\n            f\"{url}/set-cookie?warpgate-target={echo_target.name}\", headers=headers\n        )\n\n        cookies = session.cookies.get_dict()\n        assert cookies[\"cookie\"] == \"value\"\n\n    def test_gzip(\n        self,\n        setup,\n        shared_wg: WarpgateProcess,\n    ):\n        url, user, echo_target = setup\n\n        session = requests.Session()\n        session.verify = False\n        headers = {\"Host\": f\"localhost:{shared_wg.http_port}\"}\n\n        session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n            headers=headers,\n        )\n\n        response = session.get(\n            f\"{url}/gzip-response?warpgate-target={echo_target.name}\", headers=headers\n        )\n\n        assert response.text == 'response', response.text\n"
  },
  {
    "path": "tests/test_http_redirects.py",
    "content": "import requests\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess\nfrom .test_http_common import *  # noqa\n\n\nclass TestHTTPRedirects:\n    def test(\n        self,\n        shared_wg: WarpgateProcess,\n        echo_server_port,\n    ):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = api.create_target(sdk.TargetDataRequest(\n                name=f\"echo-{uuid4()}\",\n                options=sdk.TargetOptions(sdk.TargetOptionsTargetHTTPOptions(\n                    kind=\"Http\",\n                    url=f\"http://localhost:{echo_server_port}\",\n                    tls=sdk.Tls(\n                        mode=sdk.TlsMode.DISABLED,\n                        verify=False,\n                    ),\n                )),\n            ))\n            api.add_target_role(echo_target.id, role.id)\n\n        session = requests.Session()\n        session.verify = False\n        headers = {\"Host\": f\"localhost:{shared_wg.http_port}\"}\n\n        session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n            headers=headers,\n        )\n\n        response = session.get(\n            f\"{url}/redirect/http://localhost:{echo_server_port}/test?warpgate-target={echo_target.name}\",\n            headers=headers,\n            allow_redirects=False,\n        )\n\n        assert response.headers[\"location\"] == \"/test\"\n"
  },
  {
    "path": "tests/test_http_user_auth_logout.py",
    "content": "import requests\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess\nfrom .test_http_common import *  # noqa\n\n\nclass Test:\n    def test(\n        self,\n        echo_server_port,\n        shared_wg: WarpgateProcess,\n    ):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = api.create_target(sdk.TargetDataRequest(\n                name=f\"echo-{uuid4()}\",\n                options=sdk.TargetOptions(sdk.TargetOptionsTargetHTTPOptions(\n                    kind=\"Http\",\n                    url=f\"http://localhost:{echo_server_port}\",\n                    tls=sdk.Tls(\n                        mode=sdk.TlsMode.DISABLED,\n                        verify=False,\n                    ),\n                )),\n            ))\n            api.add_target_role(echo_target.id, role.id)\n\n        session = requests.Session()\n        session.verify = False\n\n        response = session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n        )\n        assert response.status_code // 100 == 2\n\n        response = session.get(\n            f\"{url}/some/path?a=b&warpgate-target={echo_target.name}&c=d\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 == 2\n        assert response.json()[\"path\"] == \"/some/path\"\n\n        response = session.post(f\"{url}/@warpgate/api/auth/logout\")\n\n        response = session.get(\n            f\"{url}/?warpgate-target={echo_target.name}\", allow_redirects=False\n        )\n        assert response.status_code // 100 != 2\n"
  },
  {
    "path": "tests/test_http_user_auth_oidc.py",
    "content": "import html\nimport re\nimport requests\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import ProcessManager\nfrom .test_http_common import *  # noqa\nfrom .util import alloc_port, wait_port\n\n\nDEFAULT_OIDC_SCOPES = [\"openid\", \"email\", \"profile\", \"preferred_username\"]\n\n\ndef _make_sso_provider_config(\n    oidc_port,\n    *,\n    auto_create_users=False,\n    role_mappings=None,\n    admin_role_mappings=None,\n    extra_scopes=None,\n):\n    \"\"\"Build an ``sso_providers`` entry for warpgate config.\"\"\"\n    scopes = list(DEFAULT_OIDC_SCOPES)\n    if extra_scopes:\n        scopes.extend(extra_scopes)\n    provider = {\n        \"type\": \"custom\",\n        \"client_id\": \"warpgate-test\",\n        \"client_secret\": \"warpgate-test-secret\",\n        \"issuer_url\": f\"http://localhost:{oidc_port}\",\n        \"scopes\": scopes,\n    }\n    if role_mappings is not None:\n        provider[\"role_mappings\"] = role_mappings\n    if admin_role_mappings is not None:\n        provider[\"admin_role_mappings\"] = admin_role_mappings\n    return {\n        \"name\": \"test-oidc\",\n        \"label\": \"OIDC Test\",\n        \"provider\": provider,\n        \"auto_create_users\": auto_create_users,\n    }\n\n\ndef _start_wg_with_oidc(processes, wg_http_port, oidc_port, **sso_kwargs):\n    \"\"\"Start a warpgate instance wired to the OIDC mock.\"\"\"\n    sso_config = _make_sso_provider_config(oidc_port, **sso_kwargs)\n    wg = processes.start_wg(\n        http_port=wg_http_port,\n        config_patch={\n            \"external_host\": \"127.0.0.1\",\n            \"sso_providers\": [sso_config],\n        },\n    )\n    wait_port(wg.http_port, for_process=wg.process, recv=False)\n    return wg\n\n\ndef _create_echo_target(api, echo_server_port, role_id):\n    \"\"\"Create an HTTP echo target and grant a role access.\"\"\"\n    target = api.create_target(\n        sdk.TargetDataRequest(\n            name=f\"echo-{uuid4()}\",\n            options=sdk.TargetOptions(\n                sdk.TargetOptionsTargetHTTPOptions(\n                    kind=\"Http\",\n                    url=f\"http://localhost:{echo_server_port}\",\n                    tls=sdk.Tls(\n                        mode=sdk.TlsMode.DISABLED,\n                        verify=False,\n                    ),\n                )\n            ),\n        )\n    )\n    api.add_target_role(target.id, role_id)\n    return target\n\n\ndef _do_oidc_login(wg_url, oidc_port, *, username=\"User1\", password=\"pwd\"):\n    \"\"\"Drive the full OIDC authorization-code flow against the mock.\n\n    Returns ``(wg_session, redirect_url)`` where *wg_session* carries the\n    authenticated cookies and *redirect_url* is warpgate's SSO-return URL\n    (already followed).\n    \"\"\"\n    from urllib.parse import urlparse, parse_qs\n\n    wg_session = requests.Session()\n    wg_session.verify = False\n\n    # Initiate SSO\n    resp = wg_session.get(f\"{wg_url}/@warpgate/api/sso/providers/test-oidc/start\")\n    assert resp.status_code == 200\n    auth_url = resp.json()[\"url\"]\n\n    # Follow to OIDC mock login page\n    oidc_session = requests.Session()\n    resp = oidc_session.get(auth_url)\n    assert resp.status_code == 200\n    login_page_url = resp.url\n    login_html = resp.text\n\n    # Extract anti-forgery token (attribute order may vary)\n    token_match = re.search(\n        r'name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]*)\"',\n        login_html,\n    )\n    if not token_match:\n        token_match = re.search(\n            r'value=\"([^\"]*)\"[^>]*name=\"__RequestVerificationToken\"',\n            login_html,\n        )\n    assert token_match, \"Could not find __RequestVerificationToken in login form\"\n    verification_token = html.unescape(token_match.group(1))\n\n    # The OIDC mock may use \"Input.ReturnUrl\" (Duende IdentityServer\n    # convention) or plain \"ReturnUrl\".  Try both, then fall back to URL.\n    return_url = None\n    m = re.search(\n        r'name=\"Input.ReturnUrl\"[^>]*value=\"([^\"]*)\"',\n        login_html,\n    )\n    assert m, \"Could not find ReturnUrl in login form\"\n    return_url = html.unescape(m.group(1))\n\n    # Detect whether the mock uses the \"Input.\" field-name prefix\n    uses_input_prefix = 'name=\"Input.' in login_html\n\n    def _field(name):\n        return f\"Input.{name}\" if uses_input_prefix else name\n\n    # Submit credentials\n    resp = oidc_session.post(\n        login_page_url,\n        data={\n            _field(\"Username\"): username,\n            _field(\"Password\"): password,\n            _field(\"Button\") if uses_input_prefix else \"button\": \"login\",\n            _field(\"ReturnUrl\"): return_url,\n            \"__RequestVerificationToken\": verification_token,\n        },\n        allow_redirects=False,\n    )\n\n    # Chase redirects until we land back at warpgate's SSO return endpoint\n    redirect_url = None\n    for _ in range(15):\n        if resp.status_code not in (301, 302, 303, 307, 308):\n            break\n        location = resp.headers[\"Location\"]\n        if location.startswith(\"/\"):\n            location = f\"http://localhost:{oidc_port}{location}\"\n        if \"/@warpgate/api/sso/return\" in location:\n            redirect_url = location\n            break\n        resp = oidc_session.get(location, allow_redirects=False)\n\n    assert redirect_url is not None, (\n        \"OIDC mock did not redirect back to warpgate's SSO return endpoint\"\n    )\n    assert \"code=\" in redirect_url, \"Redirect URL missing authorization code\"\n\n    # The OIDC redirect_uri uses 127.0.0.1 but we started the SSO flow on\n    # wg_url (localhost).  Rewrite so the session cookies (set for localhost)\n    # are sent with this request.\n    parsed_redirect = urlparse(redirect_url)\n    parsed_wg = urlparse(wg_url)\n    redirect_url = redirect_url.replace(\n        f\"{parsed_redirect.scheme}://{parsed_redirect.netloc}\",\n        f\"{parsed_wg.scheme}://{parsed_wg.netloc}\",\n        1,\n    )\n\n    # Complete the SSO flow on warpgate\n    resp = wg_session.get(redirect_url, allow_redirects=False)\n    return wg_session, resp\n\n\n# ---------------------------------------------------------------------------\n# Tests\n# ---------------------------------------------------------------------------\n\n\nclass TestHTTPUserAuthOIDC:\n    \"\"\"Tests the full OIDC authorization code flow using a mock OIDC provider.\"\"\"\n\n    def test_oidc_auth_flow(\n        self,\n        echo_server_port,\n        processes: ProcessManager,\n    ):\n        wg_http_port = alloc_port()\n        oidc_port = processes.start_oidc_server(wg_http_port)\n        wg = _start_wg_with_oidc(processes, wg_http_port, oidc_port)\n        wg_url = f\"https://127.0.0.1:{wg.http_port}\"\n\n        with admin_client(wg_url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_sso_credential(\n                user.id,\n                sdk.NewSsoCredential(\n                    email=\"sam.tailor@gmail.com\",\n                    provider=\"test-oidc\",\n                ),\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = _create_echo_target(api, echo_server_port, role.id)\n\n        # Verify SSO provider is listed\n        wg_session = requests.Session()\n        wg_session.verify = False\n        resp = wg_session.get(f\"{wg_url}/@warpgate/api/sso/providers\")\n        assert resp.status_code == 200\n        assert any(p[\"name\"] == \"test-oidc\" for p in resp.json())\n\n        wg_session, resp = _do_oidc_login(wg_url, oidc_port)\n        assert resp.status_code in (302, 307), (\n            f\"Expected redirect from SSO return, got {resp.status_code}: {resp.text[:500]}\"\n        )\n\n        # Verify authenticated access to the echo target\n        resp = wg_session.get(\n            f\"{wg_url}/some/path?a=b&warpgate-target={echo_target.name}&c=d\",\n            allow_redirects=False,\n        )\n        assert resp.status_code // 100 == 2\n        assert resp.json()[\"path\"] == \"/some/path\"\n\n    def test_oidc_auth_wrong_credentials(\n        self,\n        echo_server_port,\n        processes: ProcessManager,\n    ):\n        wg_http_port = alloc_port()\n        oidc_port = processes.start_oidc_server(wg_http_port)\n        wg = _start_wg_with_oidc(processes, wg_http_port, oidc_port)\n        wg_url = f\"https://127.0.0.1:{wg.http_port}\"\n\n        with admin_client(wg_url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_sso_credential(\n                user.id,\n                sdk.NewSsoCredential(\n                    email=\"wrong-email@example.com\",\n                    provider=\"test-oidc\",\n                ),\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = _create_echo_target(api, echo_server_port, role.id)\n\n        # Login with valid OIDC creds, but the mock user's email\n        # (sam.tailor@gmail.com) doesn't match the SSO credential.\n        wg_session, resp = _do_oidc_login(wg_url, oidc_port)\n        assert resp.status_code in (302, 307)\n        location = resp.headers.get(\"Location\", \"\")\n        assert \"login_error\" in location\n\n        # Verify user is NOT authenticated\n        resp = wg_session.get(\n            f\"{wg_url}/some/path?warpgate-target={echo_target.name}\",\n            allow_redirects=False,\n        )\n        assert resp.status_code // 100 != 2\n\n    # -- User autocreation tests -------------------------------------------\n\n    def test_oidc_auto_create_user(\n        self,\n        echo_server_port,\n        processes: ProcessManager,\n    ):\n        wg_http_port = alloc_port()\n        oidc_port = processes.start_oidc_server(wg_http_port)\n        wg = _start_wg_with_oidc(\n            processes, wg_http_port, oidc_port, auto_create_users=True\n        )\n        wg_url = f\"https://127.0.0.1:{wg.http_port}\"\n\n        # Pre-create a role and target so the auto-created user can be granted\n        # access after creation (we assign the role to the target but NOT to\n        # any user yet).\n        with admin_client(wg_url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            _create_echo_target(api, echo_server_port, role.id)\n\n            # No users exist with this SSO credential yet\n            users_before = api.get_users()\n\n        wg_session, resp = _do_oidc_login(wg_url, oidc_port)\n        assert resp.status_code in (302, 307), (\n            f\"Expected redirect after auto-creation, got {resp.status_code}\"\n        )\n\n        # Verify the user was created with the preferred_username from OIDC\n        with admin_client(wg_url) as api:\n            users_after = api.get_users()\n            new_users = [\n                u\n                for u in users_after\n                if u.username not in {ub.username for ub in users_before}\n            ]\n            assert len(new_users) == 1, (\n                f\"Expected exactly 1 new user, found {len(new_users)}\"\n            )\n            assert new_users[0].username == \"sam_tailor\"\n\n    def test_oidc_auto_create_user_disabled(\n        self,\n        echo_server_port,\n        processes: ProcessManager,\n    ):\n        \"\"\"When auto_create_users is False (the default) and no matching SSO\n        credential exists, the login should be rejected.\"\"\"\n        wg_http_port = alloc_port()\n        oidc_port = processes.start_oidc_server(wg_http_port)\n        wg = _start_wg_with_oidc(\n            processes, wg_http_port, oidc_port, auto_create_users=False\n        )\n        wg_url = f\"https://127.0.0.1:{wg.http_port}\"\n\n        # Don't create any user/SSO credential - login should fail\n        wg_session, resp = _do_oidc_login(wg_url, oidc_port)\n        assert resp.status_code in (302, 307)\n        location = resp.headers.get(\"Location\", \"\")\n        assert \"login_error\" in location\n\n    # -- Group sync / role mapping tests -----------------------------------\n\n    def test_oidc_group_sync_with_role_mappings(\n        self,\n        echo_server_port,\n        processes: ProcessManager,\n    ):\n        wg_http_port = alloc_port()\n\n        # Configure mock user with warpgate_roles claims\n        oidc_users = [\n            {\n                \"SubjectId\": \"1\",\n                \"Username\": \"User1\",\n                \"Password\": \"pwd\",\n                \"Claims\": [\n                    {\"Type\": \"name\", \"Value\": \"Sam Tailor\", \"ValueType\": \"string\"},\n                    {\n                        \"Type\": \"email\",\n                        \"Value\": \"sam.tailor@gmail.com\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"preferred_username\",\n                        \"Value\": \"sam_tailor\",\n                        \"ValueType\": \"string\",\n                    },\n                    # The OIDC mock provides multiple claims with the same type\n                    # for array values in userinfo.\n                    {\n                        \"Type\": \"warpgate_roles\",\n                        \"Value\": \"oidc-admins\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"warpgate_roles\",\n                        \"Value\": \"oidc-viewers\",\n                        \"ValueType\": \"string\",\n                    },\n                ],\n            }\n        ]\n\n        oidc_port = processes.start_oidc_server(\n            wg_http_port,\n            extra_scopes=[\"warpgate_roles\"],\n            users_override=oidc_users,\n            extra_identity_resources=[\n                {\"Name\": \"warpgate_roles\", \"ClaimTypes\": [\"warpgate_roles\"]},\n            ],\n        )\n\n        # Map OIDC groups to warpgate role names\n        role_mappings = {\n            \"oidc-admins\": \"wg-admin-role\",\n            \"oidc-viewers\": \"wg-viewer-role\",\n        }\n\n        wg = _start_wg_with_oidc(\n            processes,\n            wg_http_port,\n            oidc_port,\n            auto_create_users=True,\n            role_mappings=role_mappings,\n            extra_scopes=[\"warpgate_roles\"],\n        )\n        wg_url = f\"https://127.0.0.1:{wg.http_port}\"\n\n        with admin_client(wg_url) as api:\n            # Pre-create the roles that the mapping targets\n            admin_role = api.create_role(sdk.RoleDataRequest(name=\"wg-admin-role\"))\n            api.create_role(sdk.RoleDataRequest(name=\"wg-viewer-role\"))\n            # Also create a role that is NOT in the mapping (should not be\n            # assigned)\n            api.create_role(sdk.RoleDataRequest(name=\"wg-unrelated-role\"))\n            _create_echo_target(api, echo_server_port, admin_role.id)\n\n        wg_session, resp = _do_oidc_login(wg_url, oidc_port)\n        assert resp.status_code in (302, 307)\n\n        # Verify the auto-created user has the mapped roles\n        with admin_client(wg_url) as api:\n            users = api.get_users()\n            user = next(u for u in users if u.username == \"sam_tailor\")\n            user_roles = api.get_user_roles(user.id)\n            role_names = {r.name for r in user_roles}\n\n            assert \"wg-admin-role\" in role_names\n            assert \"wg-viewer-role\" in role_names\n            assert \"wg-unrelated-role\" not in role_names\n\n        # verify no admin roles yet\n        admin_roles = api.get_user_admin_roles(user.id)\n        assert admin_roles == []\n\n    def test_oidc_group_sync_without_mappings(\n        self,\n        echo_server_port,\n        processes: ProcessManager,\n    ):\n        \"\"\"When no role_mappings are configured, warpgate_roles claim values\n        are used directly as role names.\"\"\"\n        wg_http_port = alloc_port()\n\n        oidc_users = [\n            {\n                \"SubjectId\": \"1\",\n                \"Username\": \"User1\",\n                \"Password\": \"pwd\",\n                \"Claims\": [\n                    {\"Type\": \"name\", \"Value\": \"Sam Tailor\", \"ValueType\": \"string\"},\n                    {\n                        \"Type\": \"email\",\n                        \"Value\": \"sam.tailor@gmail.com\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"preferred_username\",\n                        \"Value\": \"sam_tailor\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"warpgate_roles\",\n                        \"Value\": \"direct-role-a\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"warpgate_roles\",\n                        \"Value\": \"direct-role-b\",\n                        \"ValueType\": \"string\",\n                    },\n                ],\n            }\n        ]\n\n        oidc_port = processes.start_oidc_server(\n            wg_http_port,\n            extra_scopes=[\"warpgate_roles\"],\n            users_override=oidc_users,\n            extra_identity_resources=[\n                {\"Name\": \"warpgate_roles\", \"ClaimTypes\": [\"warpgate_roles\"]},\n            ],\n        )\n\n        wg = _start_wg_with_oidc(\n            processes,\n            wg_http_port,\n            oidc_port,\n            auto_create_users=True,\n            extra_scopes=[\"warpgate_roles\"],\n        )\n        wg_url = f\"https://127.0.0.1:{wg.http_port}\"\n\n        with admin_client(wg_url) as api:\n            role_a = api.create_role(sdk.RoleDataRequest(name=\"direct-role-a\"))\n            api.create_role(sdk.RoleDataRequest(name=\"direct-role-b\"))\n            _create_echo_target(api, echo_server_port, role_a.id)\n\n        wg_session, resp = _do_oidc_login(wg_url, oidc_port)\n        assert resp.status_code in (302, 307)\n\n        with admin_client(wg_url) as api:\n            users = api.get_users()\n            user = next(u for u in users if u.username == \"sam_tailor\")\n            user_roles = api.get_user_roles(user.id)\n            role_names = {r.name for r in user_roles}\n\n            assert \"direct-role-a\" in role_names\n            assert \"direct-role-b\" in role_names\n\n            # admin role import without mappings\n            admin_roles = api.get_user_admin_roles(user.id)\n            assert admin_roles == []\n\n    def test_oidc_group_sync_removes_stale_roles(\n        self,\n        echo_server_port,\n        processes: ProcessManager,\n    ):\n        \"\"\"On subsequent logins, roles that are no longer present in the OIDC\n        warpgate_roles claim should be removed.\"\"\"\n        wg_http_port = alloc_port()\n\n        # First login: user has both roles\n        oidc_users_both = [\n            {\n                \"SubjectId\": \"1\",\n                \"Username\": \"User1\",\n                \"Password\": \"pwd\",\n                \"Claims\": [\n                    {\"Type\": \"name\", \"Value\": \"Sam Tailor\", \"ValueType\": \"string\"},\n                    {\n                        \"Type\": \"email\",\n                        \"Value\": \"sam.tailor@gmail.com\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"preferred_username\",\n                        \"Value\": \"sam_tailor\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"warpgate_roles\",\n                        \"Value\": \"role-keep\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"warpgate_roles\",\n                        \"Value\": \"role-remove\",\n                        \"ValueType\": \"string\",\n                    },\n                ],\n            }\n        ]\n\n        oidc_port = processes.start_oidc_server(\n            wg_http_port,\n            extra_scopes=[\"warpgate_roles\"],\n            users_override=oidc_users_both,\n            extra_identity_resources=[\n                {\"Name\": \"warpgate_roles\", \"ClaimTypes\": [\"warpgate_roles\"]},\n            ],\n        )\n\n        wg = _start_wg_with_oidc(\n            processes,\n            wg_http_port,\n            oidc_port,\n            auto_create_users=True,\n            extra_scopes=[\"warpgate_roles\"],\n        )\n        wg_url = f\"https://127.0.0.1:{wg.http_port}\"\n\n        with admin_client(wg_url) as api:\n            api.create_role(sdk.RoleDataRequest(name=\"role-keep\"))\n            role_rm = api.create_role(sdk.RoleDataRequest(name=\"role-remove\"))\n            _create_echo_target(api, echo_server_port, role_rm.id)\n\n        # First login — user gets both roles\n        _, resp = _do_oidc_login(wg_url, oidc_port)\n        assert resp.status_code in (302, 307)\n\n        with admin_client(wg_url) as api:\n            users = api.get_users()\n            user = next(u for u in users if u.username == \"sam_tailor\")\n            role_names = {r.name for r in api.get_user_roles(user.id)}\n            assert \"role-keep\" in role_names\n            assert \"role-remove\" in role_names\n\n        # Second login: start a new OIDC mock where the user only has\n        # \"role-keep\".  We need a fresh mock because the mock server's user\n        # config is fixed at startup.\n        wg_http_port2 = alloc_port()\n        oidc_users_reduced = [\n            {\n                \"SubjectId\": \"1\",\n                \"Username\": \"User1\",\n                \"Password\": \"pwd\",\n                \"Claims\": [\n                    {\"Type\": \"name\", \"Value\": \"Sam Tailor\", \"ValueType\": \"string\"},\n                    {\n                        \"Type\": \"email\",\n                        \"Value\": \"sam.tailor@gmail.com\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"preferred_username\",\n                        \"Value\": \"sam_tailor\",\n                        \"ValueType\": \"string\",\n                    },\n                    {\n                        \"Type\": \"warpgate_roles\",\n                        \"Value\": \"role-keep\",\n                        \"ValueType\": \"string\",\n                    },\n                ],\n            }\n        ]\n\n        oidc_port2 = processes.start_oidc_server(\n            wg_http_port2,\n            extra_scopes=[\"warpgate_roles\"],\n            users_override=oidc_users_reduced,\n            extra_identity_resources=[\n                {\"Name\": \"warpgate_roles\", \"ClaimTypes\": [\"warpgate_roles\"]},\n            ],\n        )\n\n        wg2 = _start_wg_with_oidc(\n            processes,\n            wg_http_port2,\n            oidc_port2,\n            auto_create_users=True,\n            extra_scopes=[\"warpgate_roles\"],\n        )\n        wg_url2 = f\"https://127.0.0.1:{wg2.http_port}\"\n\n        # The user already exists from the first wg instance's DB, but this is\n        # a fresh warpgate with its own DB.  Re-create the user so we can test\n        # the role-removal path.\n        with admin_client(wg_url2) as api:\n            api.create_role(sdk.RoleDataRequest(name=\"role-keep\"))\n            api.create_role(sdk.RoleDataRequest(name=\"role-remove\"))\n            user = api.create_user(sdk.CreateUserRequest(username=\"sam_tailor\"))\n            api.create_sso_credential(\n                user.id,\n                sdk.NewSsoCredential(\n                    email=\"sam.tailor@gmail.com\",\n                    provider=\"test-oidc\",\n                ),\n            )\n            # Pre-assign both roles\n            roles = api.get_roles()\n            for r in roles:\n                if r.name in (\"role-keep\", \"role-remove\"):\n                    api.add_user_role(user.id, r.id)\n\n        _, resp = _do_oidc_login(wg_url2, oidc_port2)\n        assert resp.status_code in (302, 307)\n\n        with admin_client(wg_url2) as api:\n            user_roles = api.get_user_roles(user.id)\n            role_names = {r.name for r in user_roles}\n            assert \"role-keep\" in role_names\n            assert \"role-remove\" not in role_names\n"
  },
  {
    "path": "tests/test_http_user_auth_otp.py",
    "content": "import requests\nimport pyotp\nfrom base64 import b64decode\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess\nfrom .test_http_common import *  # noqa\n\n\nclass TestHTTPUserAuthOTP:\n    def test_auth_otp_success(\n        self,\n        otp_key_base32,\n        otp_key_base64,\n        echo_server_port,\n        shared_wg: WarpgateProcess,\n    ):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.create_otp_credential(\n                user.id,\n                sdk.NewOtpCredential(secret_key=list(b64decode(otp_key_base64))),\n            )\n            api.update_user(\n                user.id,\n                sdk.UserDataRequest(\n                    username=user.username,\n                    credential_policy=sdk.UserRequireCredentialsPolicy(\n                        http=[\"Password\", \"Totp\"]\n                    ),\n                ),\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"echo-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetHTTPOptions(\n                            kind=\"Http\",\n                            url=f\"http://localhost:{echo_server_port}\",\n                            tls=sdk.Tls(\n                                mode=sdk.TlsMode.DISABLED,\n                                verify=False,\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(echo_target.id, role.id)\n\n        session = requests.Session()\n        session.verify = False\n\n        totp = pyotp.TOTP(otp_key_base32)\n\n        response = session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n        )\n        assert response.status_code // 100 != 2\n\n        response = session.get(\n            f\"{url}/some/path?a=b&warpgate-target={echo_target.name}&c=d\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 != 2\n\n        response = session.post(\n            f\"{url}/@warpgate/api/auth/otp\",\n            json={\n                \"otp\": totp.now(),\n            },\n        )\n        assert response.status_code // 100 == 2\n\n        response = session.get(\n            f\"{url}/some/path?a=b&warpgate-target={echo_target.name}&c=d\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 == 2\n        assert response.json()[\"path\"] == \"/some/path\"\n\n    def test_auth_otp_fail(\n        self,\n        otp_key_base64,\n        echo_server_port,\n        shared_wg: WarpgateProcess,\n    ):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.create_otp_credential(\n                user.id,\n                sdk.NewOtpCredential(secret_key=list(b64decode(otp_key_base64))),\n            )\n            api.update_user(\n                user.id,\n                sdk.UserDataRequest(\n                    username=user.username,\n                    credential_policy=sdk.UserRequireCredentialsPolicy(\n                        http=[\"Password\", \"Totp\"]\n                    ),\n                ),\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"echo-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetHTTPOptions(\n                            kind=\"Http\",\n                            url=f\"http://localhost:{echo_server_port}\",\n                            tls=sdk.Tls(\n                                mode=sdk.TlsMode.DISABLED,\n                                verify=False,\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(echo_target.id, role.id)\n\n        session = requests.Session()\n        session.verify = False\n\n        response = session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n        )\n        assert response.status_code // 100 != 2\n\n        response = session.post(\n            f\"{url}/@warpgate/api/auth/otp\",\n            json={\n                \"otp\": \"00000000\",\n            },\n        )\n        assert response.status_code // 100 != 2\n\n        response = session.get(\n            f\"{url}/some/path?a=b&warpgate-target={echo_target.name}&c=d\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 != 2\n"
  },
  {
    "path": "tests/test_http_user_auth_password.py",
    "content": "import requests\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess\nfrom .test_http_common import *  # noqa\n\n\nclass TestHTTPUserAuthPassword:\n    def test_auth_password_success(\n        self,\n        echo_server_port,\n        shared_wg: WarpgateProcess,\n    ):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"echo-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetHTTPOptions(\n                            kind=\"Http\",\n                            url=f\"http://localhost:{echo_server_port}\",\n                            tls=sdk.Tls(\n                                mode=sdk.TlsMode.DISABLED,\n                                verify=False,\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(echo_target.id, role.id)\n\n        session = requests.Session()\n        session.verify = False\n\n        response = session.get(\n            f\"{url}/?warpgate-target={echo_target.name}\", allow_redirects=False\n        )\n        assert response.status_code // 100 != 2\n\n        response = session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n        )\n        assert response.status_code // 100 == 2\n\n        response = session.get(\n            f\"{url}/some/path?a=b&warpgate-target={echo_target.name}&c=d\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 == 2\n        assert response.json()[\"path\"] == \"/some/path\"\n\n    def test_auth_password_fail(\n        self,\n        echo_server_port,\n        shared_wg: WarpgateProcess,\n    ):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"echo-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetHTTPOptions(\n                            kind=\"Http\",\n                            url=f\"http://localhost:{echo_server_port}\",\n                            tls=sdk.Tls(\n                                mode=sdk.TlsMode.DISABLED,\n                                verify=False,\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(echo_target.id, role.id)\n\n        session = requests.Session()\n        session.verify = False\n\n        response = session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"321321\",\n            },\n        )\n        assert response.status_code // 100 != 2\n\n        response = session.get(\n            f\"{url}/some/path?a=b&warpgate-target={echo_target.name}&c=d\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 != 2\n"
  },
  {
    "path": "tests/test_http_user_auth_ticket.py",
    "content": "import requests\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess\nfrom .test_http_common import *  # noqa\n\n\nclass TestHTTPUserAuthTicket:\n    def test_auth_password_success(\n        self,\n        echo_server_port,\n        shared_wg: WarpgateProcess,\n    ):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = api.create_target(sdk.TargetDataRequest(\n                name=f\"echo-{uuid4()}\",\n                options=sdk.TargetOptions(sdk.TargetOptionsTargetHTTPOptions(\n                    kind=\"Http\",\n                    url=f\"http://localhost:{echo_server_port}\",\n                    tls=sdk.Tls(\n                        mode=sdk.TlsMode.DISABLED,\n                        verify=False,\n                    ),\n                )),\n            ))\n            api.add_target_role(echo_target.id, role.id)\n\n            other_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"other-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetHTTPOptions(\n                            kind=\"Http\",\n                            url=\"http://badhost\",\n                            tls=sdk.Tls(\n                                mode=sdk.TlsMode.DISABLED,\n                                verify=False,\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(other_target.id, role.id)\n            secret = api.create_ticket(sdk.CreateTicketRequest(\n                target_name=echo_target.name,\n                username=user.username,\n            )).secret\n\n        # ---\n\n        session = requests.Session()\n        session.verify = False\n\n        response = session.get(\n            f\"{url}/some/path?warpgate-target={echo_target.name}\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 != 2\n\n        # Ticket as a header\n        response = session.get(\n            f\"{url}/some/path?warpgate-target={echo_target.name}\",\n            allow_redirects=False,\n            headers={\n                \"Authorization\": f\"Warpgate {secret}\",\n            },\n        )\n        assert response.status_code // 100 == 2\n        assert response.json()[\"path\"] == \"/some/path\"\n\n        # Bad ticket\n        response = session.get(\n            f\"{url}/some/path?warpgate-target={echo_target.name}\",\n            allow_redirects=False,\n            headers={\n                \"Authorization\": f\"Warpgate bad{secret}\",\n            },\n        )\n        assert response.status_code // 100 != 2\n\n        # Ticket as a GET param\n        session = requests.Session()\n        session.verify = False\n        response = session.get(\n            f\"{url}/some/path?warpgate-ticket={secret}\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 == 2\n        assert response.json()[\"path\"] == \"/some/path\"\n\n        # Ensure no access to other targets\n        session = requests.Session()\n        session.verify = False\n        response = session.get(\n            f\"{url}/some/path?warpgate-ticket={secret}&warpgate-target=admin\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 == 2\n\n        assert response.json()[\"path\"] == \"/some/path\"\n        response = session.get(\n            f\"{url}/some/path?warpgate-ticket={secret}&warpgate-target={other_target.name}\",\n            allow_redirects=False,\n        )\n        assert response.status_code // 100 == 2\n        assert response.json()[\"path\"] == \"/some/path\"\n"
  },
  {
    "path": "tests/test_http_websocket.py",
    "content": "import ssl\nimport requests\nfrom websocket import create_connection\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess\nfrom .test_http_common import *  # noqa\n\n\nclass TestHTTPWebsocket:\n    def test_basic(\n        self,\n        echo_server_port,\n        shared_wg: WarpgateProcess,\n    ):\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            echo_target = api.create_target(sdk.TargetDataRequest(\n                name=f\"echo-{uuid4()}\",\n                options=sdk.TargetOptions(sdk.TargetOptionsTargetHTTPOptions(\n                    kind=\"Http\",\n                    url=f\"http://localhost:{echo_server_port}\",\n                    tls=sdk.Tls(\n                        mode=sdk.TlsMode.DISABLED,\n                        verify=False,\n                    ),\n                )),\n            ))\n            api.add_target_role(echo_target.id, role.id)\n\n        session = requests.Session()\n        session.verify = False\n\n        session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n        )\n\n        cookies = session.cookies.get_dict()\n        cookie = \"; \".join([f\"{k}={v}\" for k, v in cookies.items()])\n        ws = create_connection(\n            f\"wss://localhost:{shared_wg.http_port}/socket?warpgate-target={echo_target.name}\",\n            cookie=cookie,\n            sslopt={\"cert_reqs\": ssl.CERT_NONE},\n        )\n        ws.send(\"test\")\n        assert ws.recv() == \"test\"\n        ws.send_binary(b\"test\")\n        assert ws.recv() == b\"test\"\n        ws.ping()\n        ws.close()\n"
  },
  {
    "path": "tests/test_json_logs.py",
    "content": "\"\"\"\nIntegration tests for JSON log output format.\nTests that log.format: json configuration produces valid JSON logs.\n\"\"\"\n\nimport json\nimport subprocess\nimport tempfile\nimport time\nfrom pathlib import Path\n\nimport requests\nimport yaml\n\nfrom .conftest import ProcessManager\nfrom .util import wait_port\n\n\nclass Test:\n    \"\"\"Test JSON log output format.\"\"\"\n\n    def test_json_logs_via_config(\n        self,\n        processes: ProcessManager,\n        timeout,\n    ):\n        \"\"\"Test that log.format: json in config produces JSON output.\"\"\"\n        # Create a temporary file to capture log output\n        with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as log_file:\n            log_output_path = Path(log_file.name)\n\n        try:\n            # Start Warpgate to do initial setup (this creates config)\n            wg = processes.start_wg()\n            wait_port(wg.http_port, for_process=wg.process, recv=False, timeout=timeout)\n\n            # Stop the process so we can modify the config\n            wg.process.terminate()\n            try:\n                wg.process.wait(timeout=5)\n            except subprocess.TimeoutExpired:\n                wg.process.kill()\n                wg.process.wait()\n\n            # Modify config to enable JSON logs\n            config = yaml.safe_load(wg.config_path.open())\n            config[\"log\"] = config.get(\"log\", {})\n            config[\"log\"][\"format\"] = \"json\"\n            with wg.config_path.open(\"w\") as f:\n                yaml.safe_dump(config, f)\n\n            # Restart Warpgate with JSON log config, capturing stdout\n            with open(log_output_path, \"w\") as log_capture:\n                wg_json = processes.start_wg(\n                    share_with=wg,\n                    args=[\"run\", \"--enable-admin-token\"],\n                    stdout=log_capture,\n                    stderr=subprocess.STDOUT,\n                )\n\n                # Wait for Warpgate to start\n                wait_port(wg_json.http_port, for_process=wg_json.process, recv=False, timeout=timeout)\n\n                # Give it a moment to log startup messages\n                time.sleep(1)\n\n                # Make a request to generate more logs\n                session = requests.Session()\n                session.verify = False\n                try:\n                    session.get(f\"https://localhost:{wg_json.http_port}/\", timeout=5)\n                except Exception:\n                    pass  # Expected to fail without proper auth, but will generate logs\n\n                # Give it a moment to write logs\n                time.sleep(0.5)\n\n            # Read and validate log output\n            log_content = log_output_path.read_text()\n            lines = [line.strip() for line in log_content.split(\"\\n\") if line.strip()]\n\n            assert len(lines) > 0, \"No log output captured\"\n\n            # Validate each line is valid JSON\n            json_entries = []\n            for i, line in enumerate(lines):\n                try:\n                    entry = json.loads(line)\n                    json_entries.append(entry)\n                except json.JSONDecodeError as e:\n                    raise AssertionError(f\"Line {i+1} is not valid JSON: {line[:100]}... Error: {e}\")\n\n            # Validate structure of at least one entry\n            assert len(json_entries) > 0, \"No JSON log entries found\"\n\n            # Check that entries have required fields\n            for entry in json_entries:\n                assert \"timestamp\" in entry, f\"Missing 'timestamp' field in: {entry}\"\n                assert \"level\" in entry, f\"Missing 'level' field in: {entry}\"\n                assert \"target\" in entry, f\"Missing 'target' field in: {entry}\"\n                assert \"message\" in entry, f\"Missing 'message' field in: {entry}\"\n\n                # Validate timestamp format (ISO 8601)\n                assert \"T\" in entry[\"timestamp\"], f\"Invalid timestamp format: {entry['timestamp']}\"\n\n                # Validate level is lowercase\n                assert entry[\"level\"] in [\"trace\", \"debug\", \"info\", \"warn\", \"error\"], \\\n                    f\"Invalid level: {entry['level']}\"\n\n        finally:\n            # Clean up temp file\n            log_output_path.unlink(missing_ok=True)\n\n    def test_json_logs_via_cli(\n        self,\n        processes: ProcessManager,\n        timeout,\n    ):\n        \"\"\"Test that --log-format json CLI flag produces JSON output.\"\"\"\n        # Create a temporary file to capture log output\n        with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as log_file:\n            log_output_path = Path(log_file.name)\n\n        try:\n            # Start Warpgate to do initial setup (this creates config)\n            # We need to do setup first without capturing stdout, because\n            # start_wg passes stdout to both setup and run phases\n            wg = processes.start_wg()\n            wait_port(wg.http_port, for_process=wg.process, recv=False, timeout=timeout)\n\n            # Stop the process so we can restart with CLI flag\n            wg.process.terminate()\n            try:\n                wg.process.wait(timeout=5)\n            except subprocess.TimeoutExpired:\n                wg.process.kill()\n                wg.process.wait()\n\n            # Restart Warpgate with --log-format json CLI flag, capturing stdout\n            with open(log_output_path, \"w\") as log_capture:\n                wg_json = processes.start_wg(\n                    share_with=wg,\n                    args=[\"--log-format\", \"json\", \"run\", \"--enable-admin-token\"],\n                    stdout=log_capture,\n                    stderr=subprocess.STDOUT,\n                )\n\n                # Wait for Warpgate to start\n                wait_port(wg_json.http_port, for_process=wg_json.process, recv=False, timeout=timeout)\n\n                # Give it a moment to log startup messages\n                time.sleep(1)\n\n            # Read and validate log output\n            log_content = log_output_path.read_text()\n            lines = [line.strip() for line in log_content.split(\"\\n\") if line.strip()]\n\n            assert len(lines) > 0, \"No log output captured\"\n\n            # Validate at least first line is valid JSON with required fields\n            first_line = lines[0]\n            try:\n                entry = json.loads(first_line)\n                assert \"timestamp\" in entry, f\"Missing 'timestamp' field\"\n                assert \"level\" in entry, f\"Missing 'level' field\"\n                assert \"target\" in entry, f\"Missing 'target' field\"\n                assert \"message\" in entry, f\"Missing 'message' field\"\n            except json.JSONDecodeError as e:\n                raise AssertionError(f\"First line is not valid JSON: {first_line[:100]}... Error: {e}\")\n\n        finally:\n            # Clean up temp file\n            log_output_path.unlink(missing_ok=True)\n"
  },
  {
    "path": "tests/test_kubernetes_integration.py",
    "content": "from datetime import datetime, timezone, timedelta\nimport time\nimport uuid\nimport subprocess\n\nfrom cryptography.hazmat.primitives import serialization\nfrom cryptography.hazmat.primitives.asymmetric import rsa\n\nimport aiohttp\nimport pytest\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess, K3sInstance\n\n\ndef run_kubectl(args, **kwargs):\n    return subprocess.run(\n        args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs\n    )\n\n\nclass TestKubernetesIntegration:\n    @pytest.mark.asyncio\n    async def test_kubectl_through_warpgate(\n        self, processes, shared_wg: WarpgateProcess\n    ):\n        # start k3s and obtain a service-account token\n        k3s = processes.start_k3s()\n        k3s_port = k3s.port\n        k3s_token = k3s.token\n\n        url = f\"https://localhost:{shared_wg.http_port}\"\n\n        # create user/role and give them a password\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid.uuid4()}\"))\n            user = api.create_user(\n                sdk.CreateUserRequest(username=f\"user-{uuid.uuid4()}\")\n            )\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n\n        # generate a keypair for certificate auth using cryptography (avoid\n        # depending on openssl binary in the container)\n        key = rsa.generate_private_key(public_exponent=65537, key_size=2048)\n        key_pem = key.private_bytes(\n            encoding=serialization.Encoding.PEM,\n            format=serialization.PrivateFormat.TraditionalOpenSSL,\n            encryption_algorithm=serialization.NoEncryption(),\n        ).decode()\n        pub_pem = (\n            key.public_key()\n            .public_bytes(\n                encoding=serialization.Encoding.PEM,\n                format=serialization.PublicFormat.SubjectPublicKeyInfo,\n            )\n            .decode()\n        )\n        # write the private key so that other helpers (if any) could inspect it\n        key_path = processes.ctx.tmpdir / f\"k8s-key-{uuid.uuid4()}.pem\"\n        key_path.write_text(key_pem)\n\n        # issue certificate credential for the user\n        with admin_client(url) as api:\n            issued = api.issue_certificate_credential(\n                user.id,\n                sdk.IssueCertificateCredentialRequest(\n                    label=\"kubectl-cert\",\n                    public_key_pem=pub_pem,\n                ),\n            )\n        cert_pem = issued.certificate_pem\n\n        # create a single token-based Kubernetes target (Warpgate→k8s auth)\n        token_target_name = f\"k8s-token-{uuid.uuid4()}\"\n        with admin_client(url) as api:\n            token_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=token_target_name,\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetKubernetesOptions(\n                            kind=\"Kubernetes\",\n                            cluster_url=f\"https://127.0.0.1:{k3s_port}\",\n                            tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False),\n                            auth=sdk.KubernetesTargetAuth(\n                                sdk.KubernetesTargetAuthKubernetesTargetTokenAuth(\n                                    kind=\"Token\", token=k3s_token\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(token_target.id, role.id)\n\n        # login and obtain a user API token\n        async with aiohttp.ClientSession() as session:\n            headers = {\"Host\": f\"localhost:{shared_wg.http_port}\"}\n            resp = await session.post(\n                f\"{url}/@warpgate/api/auth/login\",\n                json={\"username\": user.username, \"password\": \"123\"},\n                headers=headers,\n                ssl=False,\n            )\n            resp.raise_for_status()\n            resp = await session.post(\n                f\"{url}/@warpgate/api/profile/api-tokens\",\n                json={\n                    \"label\": \"test-token\",\n                    \"expiry\": (\n                        datetime.now(timezone.utc) + timedelta(days=1)\n                    ).isoformat(),\n                },\n                ssl=False,\n            )\n            resp.raise_for_status()\n            user_token = (await resp.json())[\"secret\"]\n\n        # positive token auth (cluster side uses token always)\n        server = f\"https://127.0.0.1:{shared_wg.kubernetes_port}/{token_target_name}\"\n        token_cmd = [\n            \"kubectl\",\n            \"get\",\n            \"pods\",\n            \"--server\",\n            server,\n            \"--insecure-skip-tls-verify\",\n            \"--token\",\n            user_token,\n            \"-n\",\n            \"default\",\n        ]\n        p = run_kubectl(token_cmd)\n        assert p.returncode == 0, f\"should accept the correct token: {p.stderr!r}\"\n\n        # negative token case\n        bad_cmd = token_cmd.copy()\n        bad_cmd[bad_cmd.index(user_token)] = user_token + \"x\"\n        p = run_kubectl(bad_cmd)\n        assert p.returncode != 0, \"should not accept an invalid token\"\n\n        # positive client-certificate auth to Warpgate (user→wg)\n        cert_file = processes.ctx.tmpdir / f\"k8s-cert-{uuid.uuid4()}.pem\"\n        key_file = processes.ctx.tmpdir / f\"k8s-key-{uuid.uuid4()}.pem\"\n        cert_file.write_text(cert_pem)\n        key_file.write_text(key_path.read_text())\n        # minimal kubeconfig again to avoid side-effects\n        kubeconf = processes.ctx.tmpdir / f\"kubeconfig-{uuid.uuid4()}.yaml\"\n        kubeconf.write_text(\"apiVersion: v1\\nkind: Config\\n\")\n        cert_cmd = [\n            \"kubectl\",\n            \"--kubeconfig\",\n            str(kubeconf),\n            \"get\",\n            \"pods\",\n            \"--server\",\n            server,\n            \"--insecure-skip-tls-verify\",\n            \"--client-certificate\",\n            str(cert_file),\n            \"--client-key\",\n            str(key_file),\n            \"-n\",\n            \"default\",\n        ]\n        p = run_kubectl(cert_cmd)\n        assert p.returncode == 0, f\"should accept the valid certificate: {p.stderr!r}\"\n\n        # negative cert to Warpgate (wrong key)\n        wrong_key = processes.ctx.tmpdir / f\"k8s-wrong-{uuid.uuid4()}.pem\"\n        # generate an unrelated key locally with cryptography\n        wrong_rsa = rsa.generate_private_key(public_exponent=65537, key_size=2048)\n        wrong_pem = wrong_rsa.private_bytes(\n            encoding=serialization.Encoding.PEM,\n            format=serialization.PrivateFormat.TraditionalOpenSSL,\n            encryption_algorithm=serialization.NoEncryption(),\n        ).decode()\n        wrong_key.write_text(wrong_pem)\n        bad_cert_cmd = cert_cmd.copy()\n        bad_cert_cmd[bad_cert_cmd.index(str(key_file))] = str(wrong_key)\n        p = run_kubectl(bad_cert_cmd)\n        assert p.returncode != 0, \"should not accept an unknown certificate\"\n\n    @pytest.mark.asyncio\n    async def test_kubectl_run(self, processes, shared_wg: WarpgateProcess):\n        \"\"\"Ensure that write requests such as ``kubectl run`` are proxied.\"\"\"\n        k3s: K3sInstance = processes.start_k3s()\n        k3s_port = k3s.port\n        k3s_token = k3s.token\n        url = f\"https://localhost:{shared_wg.http_port}\"\n\n        # create user/role as before\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid.uuid4()}\"))\n            user = api.create_user(\n                sdk.CreateUserRequest(username=f\"user-{uuid.uuid4()}\")\n            )\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n\n        target_name = f\"k8s-run-{uuid.uuid4()}\"\n        with admin_client(url) as api:\n            target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=target_name,\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetKubernetesOptions(\n                            kind=\"Kubernetes\",\n                            cluster_url=f\"https://127.0.0.1:{k3s_port}\",\n                            tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False),\n                            auth=sdk.KubernetesTargetAuth(\n                                sdk.KubernetesTargetAuthKubernetesTargetTokenAuth(\n                                    kind=\"Token\", token=k3s_token\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(target.id, role.id)\n\n        # login and request api token\n        async with aiohttp.ClientSession() as session:\n            resp = await session.post(\n                f\"{url}/@warpgate/api/auth/login\",\n                json={\"username\": user.username, \"password\": \"123\"},\n                ssl=False,\n            )\n            resp.raise_for_status()\n            resp = await session.post(\n                f\"{url}/@warpgate/api/profile/api-tokens\",\n                json={\n                    \"label\": \"run-token\",\n                    \"expiry\": (\n                        datetime.now(timezone.utc) + timedelta(days=1)\n                    ).isoformat(),\n                },\n                ssl=False,\n            )\n            resp.raise_for_status()\n            user_token = (await resp.json())[\"secret\"]\n\n        server = f\"https://127.0.0.1:{shared_wg.kubernetes_port}/{target_name}\"\n        # use interactive tty and echo some data through cat to ensure\n        # stdin/stdout forwarding works for ``kubectl run`` as well\n        run_cmd = [\n            \"kubectl\",\n            \"run\",\n            \"-v9\",\n            \"--server\",\n            server,\n            \"--insecure-skip-tls-verify\",\n            \"--token\",\n            user_token,\n            \"-n\",\n            \"default\",\n            \"test-cat\",\n            \"--image=alpine:3\",\n            \"--restart=Never\",\n            \"-i\",\n            \"--rm\",\n            \"--command\",\n            \"--\",\n            \"cat\",\n        ]\n        p = run_kubectl(\n            run_cmd,\n            input=b\"hello-from-run\\n\",\n            timeout=120,\n        )\n        assert p.returncode == 0, f\"kubectl run should succeed: {p.stderr!r}\"\n        assert b\"hello-from-run\" in p.stdout, (\n            f\"run stdout did not contain expected text: {p.stdout!r}\"\n        )\n\n    @pytest.mark.asyncio\n    async def test_mtls_upstream_and_token_user(\n        self, processes, shared_wg: WarpgateProcess\n    ):\n        # k3s already running above? start another instance for isolation\n        k3s: K3sInstance = processes.start_k3s()\n        k3s_port = k3s.port\n        # client cert/key for warpgate->k8s\n        mtls_cert = k3s.client_cert\n        mtls_key = k3s.client_key\n        # sanity‑check the values we got from start_k3s -- they must look like PEM\n        assert mtls_cert and \"BEGIN CERTIFICATE\" in mtls_cert, (\n            \"upstream cert missing or invalid\"\n        )\n        assert mtls_key and \"BEGIN\" in mtls_key, \"upstream key missing or invalid\"\n\n        url = f\"https://localhost:{shared_wg.http_port}\"\n\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid.uuid4()}\"))\n            user = api.create_user(\n                sdk.CreateUserRequest(username=f\"user-{uuid.uuid4()}\")\n            )\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n\n        # create certificate-auth target (Warpgate->k8s)\n        token_target_name = f\"k8s-mtls-{uuid.uuid4()}\"\n        with admin_client(url) as api:\n            target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=token_target_name,\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetKubernetesOptions(\n                            kind=\"Kubernetes\",\n                            cluster_url=f\"https://127.0.0.1:{k3s_port}\",\n                            tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False),\n                            auth=sdk.KubernetesTargetAuth(\n                                sdk.KubernetesTargetAuthKubernetesTargetCertificateAuth(\n                                    kind=\"Certificate\",\n                                    certificate=mtls_cert,\n                                    private_key=mtls_key,\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(target.id, role.id)\n\n        # login user and create token\n        async with aiohttp.ClientSession() as session:\n            headers = {\"Host\": f\"localhost:{shared_wg.http_port}\"}\n            resp = await session.post(\n                f\"{url}/@warpgate/api/auth/login\",\n                json={\"username\": user.username, \"password\": \"123\"},\n                headers=headers,\n                ssl=False,\n            )\n            resp.raise_for_status()\n            resp = await session.post(\n                f\"{url}/@warpgate/api/profile/api-tokens\",\n                json={\n                    \"label\": \"test-token\",\n                    \"expiry\": (\n                        datetime.now(timezone.utc) + timedelta(days=1)\n                    ).isoformat(),\n                },\n                ssl=False,\n            )\n            resp.raise_for_status()\n            user_token = (await resp.json())[\"secret\"]\n\n        server = f\"https://127.0.0.1:{shared_wg.kubernetes_port}/{token_target_name}\"\n        kubeconf = processes.ctx.tmpdir / f\"kubeconfig-{uuid.uuid4()}.yaml\"\n        kubeconf.write_text(\"apiVersion: v1\\nkind: Config\\n\")\n        # run from within container when talking to k3s directly\n        p = run_kubectl(\n            [\n                \"kubectl\",\n                \"get\",\n                \"pods\",\n                \"--server\",\n                server,\n                \"--insecure-skip-tls-verify\",\n                \"--token\",\n                user_token,\n                \"-n\",\n                \"default\",\n            ]\n        )\n        assert p.returncode == 0, \"mtls upstream token-user combo failed\"\n\n    @pytest.mark.asyncio\n    async def test_kubectl_exec_io(self, processes, shared_wg: WarpgateProcess):\n        \"\"\"Verify that ``kubectl exec`` through Warpgate proxies stdin/stdout.\"\"\"\n        k3s: K3sInstance = processes.start_k3s()\n        k3s_port = k3s.port\n        k3s_token = k3s.token\n        url = f\"https://localhost:{shared_wg.http_port}\"\n\n        # --- set up user, role, target ---\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid.uuid4()}\"))\n            user = api.create_user(\n                sdk.CreateUserRequest(username=f\"user-{uuid.uuid4()}\")\n            )\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n\n        target_name = f\"k8s-exec-{uuid.uuid4()}\"\n        with admin_client(url) as api:\n            target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=target_name,\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetKubernetesOptions(\n                            kind=\"Kubernetes\",\n                            cluster_url=f\"https://127.0.0.1:{k3s_port}\",\n                            tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False),\n                            auth=sdk.KubernetesTargetAuth(\n                                sdk.KubernetesTargetAuthKubernetesTargetTokenAuth(\n                                    kind=\"Token\", token=k3s_token\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(target.id, role.id)\n\n        # login and obtain a user API token\n        async with aiohttp.ClientSession() as session:\n            resp = await session.post(\n                f\"{url}/@warpgate/api/auth/login\",\n                json={\"username\": user.username, \"password\": \"123\"},\n                ssl=False,\n            )\n            resp.raise_for_status()\n            resp = await session.post(\n                f\"{url}/@warpgate/api/profile/api-tokens\",\n                json={\n                    \"label\": \"exec-token\",\n                    \"expiry\": (\n                        datetime.now(timezone.utc) + timedelta(days=1)\n                    ).isoformat(),\n                },\n                ssl=False,\n            )\n            resp.raise_for_status()\n            user_token = (await resp.json())[\"secret\"]\n\n        server = f\"https://127.0.0.1:{shared_wg.kubernetes_port}/{target_name}\"\n\n        # create a simple pod inside k3s\n        pod_name = f\"exec-test-{uuid.uuid4().hex[:8]}\"\n        pod_yaml = (\n            f\"apiVersion: v1\\n\"\n            f\"kind: Pod\\n\"\n            f\"metadata:\\n\"\n            f\"  name: {pod_name}\\n\"\n            f\"  namespace: default\\n\"\n            f\"spec:\\n\"\n            f\"  containers:\\n\"\n            f\"  - name: alpine\\n\"\n            f\"    image: alpine:3\\n\"\n            f\"    command: ['sleep', '3600']\\n\"\n        )\n        k3s.kubectl([\"apply\", \"-f\", \"-\"], input=pod_yaml.encode())\n\n        # wait for the pod to be Running\n        for _ in range(120):\n            r = k3s.kubectl(\n                [\n                    \"get\",\n                    \"pod\",\n                    pod_name,\n                    \"-n\",\n                    \"default\",\n                    \"-o\",\n                    \"jsonpath={.status.phase}\",\n                ],\n                check=False,\n            )\n            if r.stdout.strip() == b\"Running\":\n                break\n            time.sleep(1)\n        else:\n            raise AssertionError(\n                f\"pod {pod_name} did not reach Running: {r.stdout!r} {r.stderr!r}\"\n            )\n\n        # --- kubectl exec: send stdin and read stdout ---\n        p = run_kubectl(\n            [\n                \"kubectl\",\n                \"--server\",\n                server,\n                \"--insecure-skip-tls-verify\",\n                \"--token\",\n                user_token,\n                \"exec\",\n                \"-i\",\n                \"-n\",\n                \"default\",\n                pod_name,\n                \"--\",\n                \"cat\",\n            ],\n            input=b\"hello-from-exec\\n\",\n            timeout=30,\n        )\n        assert p.returncode == 0, f\"kubectl exec failed: {p.stderr!r}\"\n        assert b\"hello-from-exec\" in p.stdout, (\n            f\"exec stdout did not contain expected text: {p.stdout!r}\"\n        )\n\n    @pytest.mark.asyncio\n    async def test_kubectl_attach_io(self, processes, shared_wg: WarpgateProcess):\n        \"\"\"Verify that ``kubectl attach`` through Warpgate proxies stdin/stdout.\"\"\"\n        k3s: K3sInstance = processes.start_k3s()\n        k3s_port = k3s.port\n        k3s_token = k3s.token\n        url = f\"https://localhost:{shared_wg.http_port}\"\n\n        # --- set up user, role, target ---\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid.uuid4()}\"))\n            user = api.create_user(\n                sdk.CreateUserRequest(username=f\"user-{uuid.uuid4()}\")\n            )\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n\n        target_name = f\"k8s-attach-{uuid.uuid4()}\"\n        with admin_client(url) as api:\n            target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=target_name,\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetKubernetesOptions(\n                            kind=\"Kubernetes\",\n                            cluster_url=f\"https://127.0.0.1:{k3s_port}\",\n                            tls=sdk.Tls(mode=sdk.TlsMode.PREFERRED, verify=False),\n                            auth=sdk.KubernetesTargetAuth(\n                                sdk.KubernetesTargetAuthKubernetesTargetTokenAuth(\n                                    kind=\"Token\", token=k3s_token\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(target.id, role.id)\n\n        # login and obtain a user API token\n        async with aiohttp.ClientSession() as session:\n            resp = await session.post(\n                f\"{url}/@warpgate/api/auth/login\",\n                json={\"username\": user.username, \"password\": \"123\"},\n                ssl=False,\n            )\n            resp.raise_for_status()\n            resp = await session.post(\n                f\"{url}/@warpgate/api/profile/api-tokens\",\n                json={\n                    \"label\": \"attach-token\",\n                    \"expiry\": (\n                        datetime.now(timezone.utc) + timedelta(days=1)\n                    ).isoformat(),\n                },\n                ssl=False,\n            )\n            resp.raise_for_status()\n            user_token = (await resp.json())[\"secret\"]\n\n        server = f\"https://127.0.0.1:{shared_wg.kubernetes_port}/{target_name}\"\n\n        # create a pod whose main process reads stdin and echoes it back\n        pod_name = f\"attach-test-{uuid.uuid4().hex[:8]}\"\n        pod_yaml = (\n            f\"apiVersion: v1\\n\"\n            f\"kind: Pod\\n\"\n            f\"metadata:\\n\"\n            f\"  name: {pod_name}\\n\"\n            f\"  namespace: default\\n\"\n            f\"spec:\\n\"\n            f\"  containers:\\n\"\n            f\"  - name: cat\\n\"\n            f\"    image: alpine:3\\n\"\n            f\"    command: ['cat']\\n\"\n            f\"    stdin: true\\n\"\n            f\"    stdinOnce: true\\n\"\n        )\n        k3s.kubectl([\"apply\", \"-f\", \"-\"], input=pod_yaml.encode())\n\n        # wait for the pod to be Running\n        for _ in range(120):\n            r = k3s.kubectl(\n                [\n                    \"get\",\n                    \"pod\",\n                    pod_name,\n                    \"-n\",\n                    \"default\",\n                    \"-o\",\n                    \"jsonpath={.status.phase}\",\n                ],\n                check=False,\n            )\n            if r.stdout.strip() == b\"Running\":\n                break\n            time.sleep(1)\n        else:\n            raise AssertionError(\n                f\"pod {pod_name} did not reach Running: {r.stdout!r} {r.stderr!r}\"\n            )\n\n        # --- kubectl attach: send stdin and read stdout ---\n        p = run_kubectl(\n            [\n                \"kubectl\",\n                \"-v9\",\n                \"--server\",\n                server,\n                \"--insecure-skip-tls-verify\",\n                \"--token\",\n                user_token,\n                \"attach\",\n                \"-i\",\n                \"-n\",\n                \"default\",\n                pod_name,\n            ],\n            input=b\"hello-from-attach\\n\",\n            timeout=30,\n        )\n        assert p.returncode == 0, f\"kubectl attach failed: {p.stderr!r}\"\n        assert b\"hello-from-attach\" in p.stdout, (\n            f\"attach stdout did not contain expected text: {p.stdout!r}\"\n        )\n"
  },
  {
    "path": "tests/test_mysql_user_auth_password.py",
    "content": "# import subprocess\n# import time\n# from uuid import uuid4\n\n# from .api_client import (\n#     api_admin_session,\n#     api_create_target,\n#     api_create_user,\n#     api_create_role,\n#     api_add_role_to_user,\n#     api_add_role_to_target,\n# )\n# from .conftest import WarpgateProcess, ProcessManager\n# from .util import wait_port, wait_mysql_port, mysql_client_ssl_opt, mysql_client_opts\n\n\n# class Test:\n#     def test(\n#         self,\n#         processes: ProcessManager,\n#         timeout,\n#         shared_wg: WarpgateProcess,\n#     ):\n#         db_port = processes.start_mysql_server()\n#         url = f\"https://localhost:{shared_wg.http_port}\"\n#         with api_admin_session(url) as session:\n#             role = api_create_role(url, session, {\"name\": f\"role-{uuid4()}\"})\n#             user = api_create_user(\n#                 url,\n#                 session,\n#                 {\n#                     \"username\": f\"user-{uuid4()}\",\n#                     \"credentials\": [\n#                         {\n#                             \"kind\": \"Password\",\n#                             \"hash\": \"123\",\n#                         },\n#                     ],\n#                 },\n#             )\n#             api_add_role_to_user(url, session, user[\"id\"], role[\"id\"])\n#             target = api_create_target(\n#                 url,\n#                 session,\n#                 {\n#                     \"name\": f\"mysql-{uuid4()}\",\n#                     \"options\": {\n#                         \"kind\": \"MySql\",\n#                         \"host\": \"localhost\",\n#                         \"port\": db_port,\n#                         \"username\": \"root\",\n#                         \"password\": \"123\",\n#                         \"tls\": {\n#                             \"mode\": \"Preferred\",\n#                             \"verify\": False,\n#                         },\n#                     },\n#                 },\n#             )\n#             api_add_role_to_target(url, session, target[\"id\"], role[\"id\"])\n\n#         time.sleep(15)\n#         wait_mysql_port(db_port)\n#         wait_port(shared_wg.mysql_port, recv=False)\n#         time.sleep(15)\n\n#         client = processes.start(\n#             [\n#                 \"mysql\",\n#                 \"--user\",\n#                 f\"{user['username']}#{target['name']}\",\n#                 \"-p123\",\n#                 \"--host\",\n#                 \"127.0.0.1\",\n#                 \"--port\",\n#                 str(shared_wg.mysql_port),\n#                 *mysql_client_opts,\n#                 mysql_client_ssl_opt,\n#                 \"db\",\n#             ],\n#             stdin=subprocess.PIPE,\n#             stdout=subprocess.PIPE,\n#         )\n#         assert b\"\\ndb\\n\" in client.communicate(b\"show schemas;\", timeout=timeout)[0]\n#         assert client.returncode == 0\n\n#         client = processes.start(\n#             [\n#                 \"mysql\",\n#                 \"--user\",\n#                 f\"{user['username']}#{target['name']}\",\n#                 \"-pwrong\",\n#                 \"--host\",\n#                 \"127.0.0.1\",\n#                 \"--port\",\n#                 str(shared_wg.mysql_port),\n#                 *mysql_client_opts,\n#                 mysql_client_ssl_opt,\n#                 \"db\",\n#             ],\n#             stdin=subprocess.PIPE,\n#             stdout=subprocess.PIPE,\n#         )\n#         client.communicate(b\"show schemas;\", timeout=timeout)\n#         assert client.returncode != 0\n"
  },
  {
    "path": "tests/test_postgres_user_auth_in_browser.py",
    "content": "import os\nimport aiohttp\nimport pytest\nimport subprocess\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess, ProcessManager\nfrom .util import wait_port\n\n\nclass Test:\n    @pytest.mark.asyncio\n    async def test(\n        self,\n        processes: ProcessManager,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        db_port = processes.start_postgres_server()\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            api.update_user(\n                user.id,\n                sdk.UserDataRequest(\n                    username=user.username,\n                    credential_policy=sdk.UserRequireCredentialsPolicy(\n                        postgres=[\n                            sdk.CredentialKind.PASSWORD,\n                            sdk.CredentialKind.WEBUSERAPPROVAL,\n                        ],\n                    ),\n                ),\n            )\n            target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"postgres-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetPostgresOptions(\n                            kind=\"Postgres\",\n                            host=\"localhost\",\n                            port=db_port,\n                            username=\"user\",\n                            password=\"123\",\n                            tls=sdk.Tls(\n                                mode=sdk.TlsMode.PREFERRED,\n                                verify=False,\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(target.id, role.id)\n\n        wait_port(db_port, recv=False)\n        wait_port(shared_wg.postgres_port, recv=False)\n\n        session = aiohttp.ClientSession()\n        headers = {\"Host\": f\"localhost:{shared_wg.http_port}\"}\n\n        await session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n            headers=headers,\n            ssl=False,\n        )\n        ws = await session.ws_connect(url.replace('https:', 'wss:') + '/@warpgate/api/auth/web-auth-requests/stream', ssl=False)\n\n        client = processes.start(\n            [\n                \"psql\",\n                \"--user\",\n                f\"{user.username}#{target.name}\",\n                \"--host\",\n                \"127.0.0.1\",\n                \"--port\",\n                str(shared_wg.postgres_port),\n                \"db\",\n            ],\n            env={\"PGPASSWORD\": \"123\", **os.environ},\n            stdin=subprocess.PIPE,\n            stdout=subprocess.PIPE,\n        )\n\n        # First message comes as soon as an authstate is created\n        msg = await ws.receive(5)\n        # Second message is once password is accepted\n        msg = await ws.receive(5)\n\n        auth_id = msg.data\n        auth_state = await (await session.get(f'{url}/@warpgate/api/auth/state/{auth_id}', ssl=False)).json()\n        assert auth_state['protocol'] == 'PostgreSQL'\n        assert auth_state['state'] == 'WebUserApprovalNeeded'\n        r = await session.post(f'{url}/@warpgate/api/auth/state/{auth_id}/approve', ssl=False)\n        assert r.status == 200\n\n        client.stdin.write(b\"\\r\\n\")\n\n        assert b\"tbl\" in client.communicate(b\"\\\\dt\\n\", timeout=timeout)[0]\n        assert client.returncode == 0\n"
  },
  {
    "path": "tests/test_postgres_user_auth_password.py",
    "content": "import os\nimport subprocess\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import WarpgateProcess, ProcessManager\nfrom .util import wait_port\n\n\nclass Test:\n    def test(\n        self,\n        processes: ProcessManager,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        db_port = processes.start_postgres_server()\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(sdk.RoleDataRequest(name=f\"role-{uuid4()}\"))\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            target = api.create_target(sdk.TargetDataRequest(\n                name=f\"postgres-{uuid4()}\",\n                options=sdk.TargetOptions(sdk.TargetOptionsTargetPostgresOptions(\n                    kind=\"Postgres\",\n                    host=\"localhost\",\n                    port=db_port,\n                    username=\"user\",\n                    password=\"123\",\n                    tls=sdk.Tls(\n                        mode=sdk.TlsMode.PREFERRED,\n                        verify=False,\n                    ),\n                )),\n            ))\n            api.add_target_role(target.id, role.id)\n\n        wait_port(db_port, recv=False)\n        wait_port(shared_wg.postgres_port, recv=False)\n\n        client = processes.start(\n            [\n                \"psql\",\n                \"--user\",\n                f\"{user.username}#{target.name}\",\n                \"--host\",\n                \"127.0.0.1\",\n                \"--port\",\n                str(shared_wg.postgres_port),\n                \"db\",\n            ],\n            env={\"PGPASSWORD\": \"123\", **os.environ},\n            stdin=subprocess.PIPE,\n            stdout=subprocess.PIPE,\n        )\n        assert b\"tbl\" in client.communicate(b\"\\\\dt\\n\", timeout=timeout)[0]\n        assert client.returncode == 0\n\n        client = processes.start(\n            [\n                \"psql\",\n                \"--user\",\n                f\"{user.username}#{target.name}\",\n                \"--host\",\n                \"127.0.0.1\",\n                \"--port\",\n                str(shared_wg.postgres_port),\n                \"db\",\n            ],\n            env={\"PGPASSWORD\": \"wrong\", **os.environ},\n            stdin=subprocess.PIPE,\n            stdout=subprocess.PIPE,\n        )\n        client.communicate(b\"\\\\dt\\n\", timeout=timeout)\n        assert client.returncode != 0\n"
  },
  {
    "path": "tests/test_ssh_client_auth_config.py",
    "content": "\"\"\"\nTests for SSH client authentication method configuration via Parameters API.\n\nThese tests verify that SSH auth methods can be configured via the Parameters API\nand that the configuration actually affects SSH authentication behavior.\n\"\"\"\nimport time\nfrom pathlib import Path\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import ProcessManager, WarpgateProcess\nfrom .util import wait_port\n\n\nclass TestSSHClientAuthConfigAPI:\n    \"\"\"Test SSH client authentication method configuration via API.\"\"\"\n\n    def test_get_ssh_auth_parameters(\n        self,\n        shared_wg: WarpgateProcess,\n    ):\n        \"\"\"Test that SSH auth parameters are returned by the API.\"\"\"\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            params = api.get_parameters()\n            # Verify the new SSH auth fields exist and default to True\n            assert hasattr(params, 'ssh_client_auth_publickey')\n            assert hasattr(params, 'ssh_client_auth_password')\n            assert hasattr(params, 'ssh_client_auth_keyboard_interactive')\n            # Default values should be True\n            assert params.ssh_client_auth_publickey is True\n            assert params.ssh_client_auth_password is True\n            assert params.ssh_client_auth_keyboard_interactive is True\n\n    def test_update_ssh_auth_parameters(\n        self,\n        shared_wg: WarpgateProcess,\n    ):\n        \"\"\"Test that SSH auth parameters can be updated via the API.\"\"\"\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            # Get current parameters\n            params = api.get_parameters()\n\n            # Update to disable password auth\n            api.update_parameters(sdk.ParameterUpdate(\n                allow_own_credential_management=params.allow_own_credential_management,\n                rate_limit_bytes_per_second=params.rate_limit_bytes_per_second,\n                ssh_client_auth_publickey=True,\n                ssh_client_auth_password=False,\n                ssh_client_auth_keyboard_interactive=True,\n            ))\n\n            # Verify the update\n            updated_params = api.get_parameters()\n            assert updated_params.ssh_client_auth_password is False\n            assert updated_params.ssh_client_auth_publickey is True\n\n            # Restore original settings\n            api.update_parameters(sdk.ParameterUpdate(\n                allow_own_credential_management=params.allow_own_credential_management,\n                rate_limit_bytes_per_second=params.rate_limit_bytes_per_second,\n                ssh_client_auth_publickey=True,\n                ssh_client_auth_password=True,\n                ssh_client_auth_keyboard_interactive=True,\n            ))\n\n\nclass TestSSHClientAuthConfigE2E:\n    \"\"\"E2E tests verifying SSH auth methods are actually enforced.\"\"\"\n\n    def _start_ssh_server(self, processes, wg_c_ed25519_pubkey):\n        \"\"\"Start SSH server with delay for Docker port forwarding.\"\"\"\n        ssh_port = processes.start_ssh_server(\n            trusted_keys=[wg_c_ed25519_pubkey.read_text()]\n        )\n        # Give Docker time to set up port forwarding\n        time.sleep(3)\n        wait_port(ssh_port)\n        return ssh_port\n\n    def _setup_user_and_target(self, api, ssh_port, wg_c_ed25519_pubkey: Path):\n        \"\"\"Helper to create user, credentials, and target.\"\"\"\n        role = api.create_role(\n            sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n        )\n        user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n        # Add password credential\n        api.create_password_credential(\n            user.id, sdk.NewPasswordCredential(password=\"testpass123\")\n        )\n        # Add pubkey credential\n        api.create_public_key_credential(\n            user.id,\n            sdk.NewPublicKeyCredential(\n                label=\"Public Key\",\n                openssh_public_key=open(\"ssh-keys/id_ed25519.pub\").read().strip()\n            ),\n        )\n        api.add_user_role(user.id, role.id)\n        ssh_target = api.create_target(\n            sdk.TargetDataRequest(\n                name=f\"ssh-{uuid4()}\",\n                options=sdk.TargetOptions(\n                    sdk.TargetOptionsTargetSSHOptions(\n                        kind=\"Ssh\",\n                        host=\"localhost\",\n                        port=ssh_port,\n                        username=\"root\",\n                        auth=sdk.SSHTargetAuth(\n                            sdk.SSHTargetAuthSshTargetPublicKeyAuth(\n                                kind=\"PublicKey\"\n                            )\n                        ),\n                    )\n                ),\n            )\n        )\n        api.add_target_role(ssh_target.id, role.id)\n        return user, ssh_target\n\n    def _update_ssh_auth_params(self, api, pubkey=True, password=True, keyboard_interactive=True):\n        \"\"\"Helper to update SSH auth parameters.\"\"\"\n        params = api.get_parameters()\n        api.update_parameters(sdk.ParameterUpdate(\n            allow_own_credential_management=params.allow_own_credential_management,\n            rate_limit_bytes_per_second=params.rate_limit_bytes_per_second,\n            ssh_client_auth_publickey=pubkey,\n            ssh_client_auth_password=password,\n            ssh_client_auth_keyboard_interactive=keyboard_interactive,\n        ))\n\n    def test_password_auth_disabled(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        \"\"\"Test that disabling password auth blocks password-based SSH login.\"\"\"\n        ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey)\n\n        wg = processes.start_wg()\n        wait_port(wg.http_port, for_process=wg.process, recv=False)\n        wait_port(wg.ssh_port, for_process=wg.process)\n\n        url = f\"https://localhost:{wg.http_port}\"\n        with admin_client(url) as api:\n            user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey)\n            self._update_ssh_auth_params(api, pubkey=True, password=False, keyboard_interactive=False)\n\n        wg.process.terminate()\n        wg.process.wait()\n\n        wg2 = processes.start_wg(share_with=wg)\n        wait_port(wg2.http_port, for_process=wg2.process, recv=False)\n        wait_port(wg2.ssh_port, for_process=wg2.process)\n\n        # Try password auth - should fail\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\", str(wg2.ssh_port),\n            \"-i\", \"/dev/null\",\n            \"-o\", \"PreferredAuthentications=password\",\n            \"-o\", \"NumberOfPasswordPrompts=1\",\n            \"ls\", \"/bin/sh\",\n            password=\"testpass123\",\n        )\n        ssh_client.communicate(timeout=timeout)\n        assert ssh_client.returncode != 0\n\n    def test_pubkey_auth_disabled(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        \"\"\"Test that disabling pubkey auth blocks pubkey-based SSH login.\"\"\"\n        ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey)\n\n        wg = processes.start_wg()\n        wait_port(wg.http_port, for_process=wg.process, recv=False)\n        wait_port(wg.ssh_port, for_process=wg.process)\n\n        url = f\"https://localhost:{wg.http_port}\"\n        with admin_client(url) as api:\n            user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey)\n            self._update_ssh_auth_params(api, pubkey=False, password=True, keyboard_interactive=False)\n\n        wg.process.terminate()\n        wg.process.wait()\n\n        wg2 = processes.start_wg(share_with=wg)\n        wait_port(wg2.http_port, for_process=wg2.process, recv=False)\n        wait_port(wg2.ssh_port, for_process=wg2.process)\n\n        # Try pubkey auth - should fail\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\", str(wg2.ssh_port),\n            \"-o\", \"IdentityFile=ssh-keys/id_ed25519\",\n            \"-o\", \"PreferredAuthentications=publickey\",\n            \"ls\", \"/bin/sh\",\n        )\n        ssh_client.communicate(timeout=timeout)\n        assert ssh_client.returncode != 0\n\n    def test_pubkey_auth_enabled_works(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        \"\"\"Test that pubkey auth works when enabled.\"\"\"\n        ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey)\n\n        wg = processes.start_wg()\n        wait_port(wg.http_port, for_process=wg.process, recv=False)\n        wait_port(wg.ssh_port, for_process=wg.process)\n\n        url = f\"https://localhost:{wg.http_port}\"\n        with admin_client(url) as api:\n            user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey)\n            self._update_ssh_auth_params(api, pubkey=True, password=False, keyboard_interactive=False)\n\n        wg.process.terminate()\n        wg.process.wait()\n\n        wg2 = processes.start_wg(share_with=wg)\n        wait_port(wg2.http_port, for_process=wg2.process, recv=False)\n        wait_port(wg2.ssh_port, for_process=wg2.process)\n\n        # Try pubkey auth - should succeed\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\", str(wg2.ssh_port),\n            \"-o\", \"IdentityFile=ssh-keys/id_ed25519\",\n            \"-o\", \"PreferredAuthentications=publickey\",\n            \"ls\", \"/bin/sh\",\n        )\n        output, _ = ssh_client.communicate(timeout=timeout)\n        assert output == b\"/bin/sh\\n\"\n        assert ssh_client.returncode == 0\n\n    def test_password_auth_enabled_works(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        \"\"\"Test that password auth works when enabled.\"\"\"\n        ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey)\n\n        wg = processes.start_wg()\n        wait_port(wg.http_port, for_process=wg.process, recv=False)\n        wait_port(wg.ssh_port, for_process=wg.process)\n\n        url = f\"https://localhost:{wg.http_port}\"\n        with admin_client(url) as api:\n            user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey)\n            self._update_ssh_auth_params(api, pubkey=False, password=True, keyboard_interactive=False)\n\n        wg.process.terminate()\n        wg.process.wait()\n\n        wg2 = processes.start_wg(share_with=wg)\n        wait_port(wg2.http_port, for_process=wg2.process, recv=False)\n        wait_port(wg2.ssh_port, for_process=wg2.process)\n\n        # Try password auth - should succeed\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\", str(wg2.ssh_port),\n            \"-i\", \"/dev/null\",\n            \"-o\", \"PreferredAuthentications=password\",\n            \"ls\", \"/bin/sh\",\n            password=\"testpass123\",\n        )\n        output, _ = ssh_client.communicate(timeout=timeout)\n        assert output == b\"/bin/sh\\n\"\n        assert ssh_client.returncode == 0\n\n    def test_both_auth_methods_work(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        \"\"\"Test both pubkey and password work when both enabled.\"\"\"\n        ssh_port = self._start_ssh_server(processes, wg_c_ed25519_pubkey)\n\n        wg = processes.start_wg()\n        wait_port(wg.http_port, for_process=wg.process, recv=False)\n        wait_port(wg.ssh_port, for_process=wg.process)\n\n        url = f\"https://localhost:{wg.http_port}\"\n        with admin_client(url) as api:\n            user, ssh_target = self._setup_user_and_target(api, ssh_port, wg_c_ed25519_pubkey)\n            self._update_ssh_auth_params(api, pubkey=True, password=True, keyboard_interactive=False)\n\n        wg.process.terminate()\n        wg.process.wait()\n\n        wg2 = processes.start_wg(share_with=wg)\n        wait_port(wg2.http_port, for_process=wg2.process, recv=False)\n        wait_port(wg2.ssh_port, for_process=wg2.process)\n\n        # Pubkey should work\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\", str(wg2.ssh_port),\n            \"-o\", \"IdentityFile=ssh-keys/id_ed25519\",\n            \"-o\", \"PreferredAuthentications=publickey\",\n            \"ls\", \"/bin/sh\",\n        )\n        output, _ = ssh_client.communicate(timeout=timeout)\n        assert output == b\"/bin/sh\\n\"\n        assert ssh_client.returncode == 0\n\n        # Password should also work\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\", str(wg2.ssh_port),\n            \"-i\", \"/dev/null\",\n            \"-o\", \"PreferredAuthentications=password\",\n            \"ls\", \"/bin/sh\",\n            password=\"testpass123\",\n        )\n        output, _ = ssh_client.communicate(timeout=timeout)\n        assert output == b\"/bin/sh\\n\"\n        assert ssh_client.returncode == 0\n"
  },
  {
    "path": "tests/test_ssh_proto.py",
    "content": "from uuid import uuid4\nimport requests\nimport subprocess\nimport tempfile\nimport time\nimport pytest\nfrom textwrap import dedent\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import ProcessManager, WarpgateProcess\nfrom .util import wait_port, alloc_port\n\n\n@pytest.fixture(scope=\"session\")\ndef ssh_port(processes, wg_c_ed25519_pubkey):\n    yield processes.start_ssh_server(trusted_keys=[wg_c_ed25519_pubkey.read_text()])\n\n\ncommon_args = [\n    \"-i\",\n    \"/dev/null\",\n    \"-o\",\n    \"PreferredAuthentications=password\",\n]\n\n\ndef setup_user_and_target(\n    processes: ProcessManager,\n    wg: WarpgateProcess,\n    warpgate_client_key,\n    extra_config='',\n):\n    ssh_port = processes.start_ssh_server(\n        trusted_keys=[warpgate_client_key.read_text()],\n        extra_config=extra_config,\n    )\n    wait_port(ssh_port)\n\n    url = f\"https://localhost:{wg.http_port}\"\n    with admin_client(url) as api:\n        role = api.create_role(\n            sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n        )\n        user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n        api.create_password_credential(\n            user.id, sdk.NewPasswordCredential(password=\"123\")\n        )\n        api.create_public_key_credential(\n            user.id,\n            sdk.NewPublicKeyCredential(\n                label=\"Public Key\",\n                openssh_public_key=open(\"ssh-keys/id_ed25519.pub\").read().strip(),\n            ),\n        )\n        api.add_user_role(user.id, role.id)\n        ssh_target = api.create_target(\n            sdk.TargetDataRequest(\n                name=f\"ssh-{uuid4()}\",\n                options=sdk.TargetOptions(\n                    sdk.TargetOptionsTargetSSHOptions(\n                        kind=\"Ssh\",\n                        host=\"localhost\",\n                        port=ssh_port,\n                        username=\"root\",\n                        auth=sdk.SSHTargetAuth(\n                            sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind=\"PublicKey\")\n                        ),\n                    )\n                ),\n            )\n        )\n        api.add_target_role(ssh_target.id, role.id)\n        return user, ssh_target\n\n\nclass Test:\n    def test_stdout_stderr(\n        self,\n        processes: ProcessManager,\n        timeout,\n        wg_c_ed25519_pubkey,\n        shared_wg: WarpgateProcess,\n    ):\n        user, ssh_target = setup_user_and_target(\n            processes, shared_wg, wg_c_ed25519_pubkey\n        )\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            *common_args,\n            \"sh\",\n            \"-c\",\n            '\"echo -n stdout; echo -n stderr >&2\"',\n            password=\"123\",\n            stderr=subprocess.PIPE,\n        )\n\n        stdout, stderr = ssh_client.communicate(timeout=timeout)\n        assert b\"stdout\" == stdout\n        assert stderr.endswith(b\"stderr\")\n\n    def test_pty(\n        self,\n        processes: ProcessManager,\n        timeout,\n        wg_c_ed25519_pubkey,\n        shared_wg: WarpgateProcess,\n    ):\n        user, ssh_target = setup_user_and_target(\n            processes, shared_wg, wg_c_ed25519_pubkey\n        )\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-tt\",\n            *common_args,\n            \"echo\",\n            \"hello\",\n            password=\"123\",\n        )\n\n        output = ssh_client.communicate(timeout=timeout)[0]\n        assert b\"Warpgate\" in output\n        assert b\"Selected target:\" in output\n        assert b\"hello\\r\\n\" in output\n\n    def test_signals(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey,\n        shared_wg: WarpgateProcess,\n    ):\n        user, ssh_target = setup_user_and_target(\n            processes, shared_wg, wg_c_ed25519_pubkey\n        )\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-v\",\n            *common_args,\n            \"sh\",\n            \"-c\",\n            '\"pkill -9 sh\"',\n            password=\"123\",\n        )\n\n        assert ssh_client.returncode != 0\n\n    def test_direct_tcpip(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey,\n        shared_wg: WarpgateProcess,\n        timeout,\n    ):\n        user, ssh_target = setup_user_and_target(\n            processes, shared_wg, wg_c_ed25519_pubkey\n        )\n        local_port = alloc_port()\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-v\",\n            *common_args,\n            \"-L\",\n            f\"{local_port}:github.com:443\",\n            \"-N\",\n            password=\"123\",\n        )\n\n        time.sleep(10)\n\n        wait_port(local_port, recv=False)\n\n        s = requests.Session()\n        retries = requests.adapters.Retry(total=5, backoff_factor=1)\n        s.mount(\"https://\", requests.adapters.HTTPAdapter(max_retries=retries))\n        response = s.get(f\"https://localhost:{local_port}\", timeout=timeout, verify=False)\n        assert response.status_code == 200\n        ssh_client.kill()\n\n    def test_tcpip_forward(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey,\n        shared_wg: WarpgateProcess,\n        timeout,\n    ):\n        user, ssh_target = setup_user_and_target(\n            processes, shared_wg, wg_c_ed25519_pubkey\n        )\n        fw_port = alloc_port()\n        pf_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-v\",\n            *common_args,\n            \"-R\",\n            f\"{fw_port}:www.google.com:443\",\n            \"-N\",\n            password=\"123\",\n        )\n        # time.sleep(5)\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-v\",\n            *common_args,\n            \"curl\",\n            \"-vk\",\n            \"--http1.1\",\n            \"-H\", \"Host: www.google.com\",\n            f\"https://localhost:{fw_port}\",\n            password=\"123\",\n        )\n        output = ssh_client.communicate(timeout=timeout)[0]\n        assert ssh_client.returncode == 0\n        assert b\"</html>\" in output\n        pf_client.kill()\n\n    def test_shell(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey,\n        shared_wg: WarpgateProcess,\n        timeout,\n    ):\n        user, ssh_target = setup_user_and_target(\n            processes, shared_wg, wg_c_ed25519_pubkey\n        )\n        script = dedent(\n            f\"\"\"\n            set timeout {timeout - 5}\n\n            spawn ssh -tt {user.username}:{ssh_target.name}@localhost -p {shared_wg.ssh_port} -o StrictHostKeychecking=no -o UserKnownHostsFile=/dev/null -o PreferredAuthentications=password\n\n            expect \"password:\"\n            sleep 0.5\n            send \"123\\\\r\"\n\n            expect \"#\"\n            sleep 0.5\n            send \"ls /bin/sh\\\\r\"\n            send \"exit\\\\r\"\n\n            expect {{\n                \"/bin/sh\"  {{ exit 0; }}\n                eof {{ exit 1; }}\n            }}\n\n            exit 1\n            \"\"\"\n        )\n\n        ssh_client = processes.start(\n            [\"expect\", \"-d\"], stdin=subprocess.PIPE, stdout=subprocess.PIPE\n        )\n\n        output = ssh_client.communicate(script.encode(), timeout=timeout)[0]\n        assert ssh_client.returncode == 0, output\n\n    def test_connection_error(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey,\n        shared_wg: WarpgateProcess,\n    ):\n        user, ssh_target = setup_user_and_target(\n            processes, shared_wg, wg_c_ed25519_pubkey\n        )\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-tt\",\n            \"user:ssh-bad-domain@localhost\",\n            \"-i\",\n            \"/dev/null\",\n            \"-o\",\n            \"PreferredAuthentications=password\",\n            password=\"123\",\n        )\n\n        assert ssh_client.returncode != 0\n\n    def test_sftp(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey,\n        shared_wg: WarpgateProcess,\n    ):\n        user, ssh_target = setup_user_and_target(\n            processes, shared_wg, wg_c_ed25519_pubkey\n        )\n        with tempfile.TemporaryDirectory() as f:\n            subprocess.check_call(\n                [\n                    \"sftp\",\n                    \"-P\",\n                    str(shared_wg.ssh_port),\n                    \"-o\",\n                    f\"User={user.username}:{ssh_target.name}\",\n                    \"-o\",\n                    \"IdentitiesOnly=yes\",\n                    \"-o\",\n                    \"IdentityFile=ssh-keys/id_ed25519\",\n                    \"-o\",\n                    \"PreferredAuthentications=publickey\",\n                    \"-o\",\n                    \"StrictHostKeychecking=no\",\n                    \"-o\",\n                    \"UserKnownHostsFile=/dev/null\",\n                    \"localhost:/etc/passwd\",\n                    f,\n                ],\n                stdout=subprocess.PIPE,\n            )\n\n            assert \"root:x:0:0:root\" in open(f + \"/passwd\").read()\n\n    def test_insecure_protos(\n        self,\n        processes: ProcessManager,\n        timeout,\n        wg_c_rsa_pubkey,\n        shared_wg: WarpgateProcess,\n    ):\n        user, ssh_target = setup_user_and_target(\n            processes, shared_wg, wg_c_rsa_pubkey,\n            extra_config='''\n            PubkeyAcceptedKeyTypes=ssh-rsa\n            ''',\n        )\n\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            *common_args,\n            \"echo\", \"123\",\n            password=\"123\",\n            stderr=subprocess.PIPE,\n        )\n\n        ssh_client.wait(timeout=timeout)\n        assert ssh_client.returncode != 0\n\n        ssh_target.options.actual_instance.allow_insecure_algos = True\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            api.update_target(ssh_target.id, sdk.TargetDataRequest(\n                name=ssh_target.name,\n                options=ssh_target.options,\n            ))\n\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            *common_args,\n            \"echo\", \"123\",\n            password=\"123\",\n        )\n\n        stdout, _ = ssh_client.communicate(timeout=timeout)\n        assert b\"123\\n\" == stdout\n"
  },
  {
    "path": "tests/test_ssh_target_selection.py",
    "content": "from pathlib import Path\nimport subprocess\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import ProcessManager, WarpgateProcess\nfrom .util import wait_port\n\n\nclass Test:\n    def test_bad_target(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        shared_wg: WarpgateProcess,\n    ):\n        ssh_port = processes.start_ssh_server(\n            trusted_keys=[wg_c_ed25519_pubkey.read_text()]\n        )\n\n        wait_port(ssh_port)\n\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(\n                sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n            )\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(user.id, sdk.NewPasswordCredential(password=\"123\"))\n            api.add_user_role(user.id, role.id)\n            ssh_target = api.create_target(sdk.TargetDataRequest(\n                name=f\"ssh-{uuid4()}\",\n                options=sdk.TargetOptions(\n                    sdk.TargetOptionsTargetSSHOptions(\n                        kind=\"Ssh\",\n                        host=\"localhost\",\n                        port=ssh_port,\n                        username=\"root\",\n                        auth=sdk.SSHTargetAuth(\n                            sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind=\"PublicKey\")\n                        ),\n                    )\n                ),\n            ))\n            api.add_target_role(ssh_target.id, role.id)\n\n        ssh_client = processes.start_ssh_client(\n            \"-t\",\n            f\"{user.username}:badtarget@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-i\",\n            \"/dev/null\",\n            \"-o\",\n            \"PreferredAuthentications=password\",\n            \"echo\",\n            \"hello\",\n            stderr=subprocess.PIPE,\n            password=\"123\",\n        )\n        assert ssh_client.returncode != 0\n        assert b\"Permission denied\" in ssh_client.stderr.read()\n"
  },
  {
    "path": "tests/test_ssh_user_auth_in_browser.py",
    "content": "import aiohttp\nimport pytest\nfrom pathlib import Path\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import ProcessManager, WarpgateProcess\nfrom .util import wait_port\n\n\nclass Test:\n    # When include_pk is False, we're testing for\n    # https://github.com/warp-tech/warpgate/issues/972\n    # where the SSH server fails to offer keyboard-interactive authentication\n    # when no OTP credential is present.\n    @pytest.mark.parametrize(\"include_pk\", [True, False])\n    @pytest.mark.asyncio\n    async def test(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n        include_pk: bool,\n    ):\n        ssh_port = processes.start_ssh_server(\n            trusted_keys=[wg_c_ed25519_pubkey.read_text()]\n        )\n\n        wait_port(ssh_port)\n\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(\n                sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n            )\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            if include_pk:\n                api.create_public_key_credential(\n                    user.id,\n                    sdk.NewPublicKeyCredential(\n                        label=\"Public Key\",\n                        openssh_public_key=open(\"ssh-keys/id_ed25519.pub\").read().strip()\n                    ),\n                )\n            api.add_user_role(user.id, role.id)\n            api.update_user(\n                user.id,\n                sdk.UserDataRequest(\n                    username=user.username,\n                    credential_policy=sdk.UserRequireCredentialsPolicy(\n                        ssh=[sdk.CredentialKind.WEBUSERAPPROVAL] if not include_pk else [\n                            sdk.CredentialKind.PUBLICKEY,\n                            sdk.CredentialKind.WEBUSERAPPROVAL,\n                        ],\n                    ),\n                ),\n            )\n            ssh_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"ssh-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetSSHOptions(\n                            kind=\"Ssh\",\n                            host=\"localhost\",\n                            port=ssh_port,\n                            username=\"root\",\n                            auth=sdk.SSHTargetAuth(\n                                sdk.SSHTargetAuthSshTargetPublicKeyAuth(\n                                    kind=\"PublicKey\"\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(ssh_target.id, role.id)\n\n        session = aiohttp.ClientSession()\n        headers = {\"Host\": f\"localhost:{shared_wg.http_port}\"}\n\n        await session.post(\n            f\"{url}/@warpgate/api/auth/login\",\n            json={\n                \"username\": user.username,\n                \"password\": \"123\",\n            },\n            headers=headers,\n            ssl=False,\n        )\n        ws = await session.ws_connect(url.replace('https:', 'wss:') + '/@warpgate/api/auth/web-auth-requests/stream', ssl=False)\n\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-o\",\n            \"IdentityFile=ssh-keys/id_ed25519\",\n            \"ls\",\n            \"/bin/sh\",\n        )\n\n        msg = await ws.receive(5)\n\n        auth_id = msg.data\n        auth_state = await (await session.get(f'{url}/@warpgate/api/auth/state/{auth_id}', ssl=False)).json()\n        assert auth_state['protocol'] == 'SSH'\n        assert auth_state['state'] == 'WebUserApprovalNeeded'\n        r = await session.post(f'{url}/@warpgate/api/auth/state/{auth_id}/approve', ssl=False)\n        assert r.status == 200\n\n        ssh_client.stdin.write(b\"\\r\\n\")\n\n        assert ssh_client.communicate(timeout=timeout)[0] == b\"/bin/sh\\n\"\n        assert ssh_client.returncode == 0\n"
  },
  {
    "path": "tests/test_ssh_user_auth_otp.py",
    "content": "from asyncio import subprocess\nfrom base64 import b64decode\nfrom uuid import uuid4\nimport pyotp\nimport pytest\nfrom pathlib import Path\nfrom textwrap import dedent\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import ProcessManager, WarpgateProcess\nfrom .util import wait_port\n\n\nclass Test:\n    def test_otp(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        otp_key_base32: str,\n        otp_key_base64: str,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        ssh_port = processes.start_ssh_server(\n            trusted_keys=[wg_c_ed25519_pubkey.read_text()]\n        )\n\n        wait_port(ssh_port)\n\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(\n                sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n            )\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_public_key_credential(\n                user.id,\n                sdk.NewPublicKeyCredential(\n                    label=\"Public Key\",\n                    openssh_public_key=open(\"ssh-keys/id_ed25519.pub\").read().strip(),\n                ),\n            )\n            api.create_otp_credential(\n                user.id,\n                sdk.NewOtpCredential(\n                    secret_key=list(b64decode(otp_key_base64)),\n                ),\n            )\n            api.update_user(\n                user.id,\n                sdk.UserDataRequest(\n                    username=user.username,\n                    credential_policy=sdk.UserRequireCredentialsPolicy(\n                        ssh=[\"PublicKey\", \"Totp\"],\n                    ),\n                ),\n            )\n            api.add_user_role(user.id, role.id)\n            ssh_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"ssh-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetSSHOptions(\n                            kind=\"Ssh\",\n                            host=\"localhost\",\n                            port=ssh_port,\n                            username=\"root\",\n                            auth=sdk.SSHTargetAuth(\n                                sdk.SSHTargetAuthSshTargetPublicKeyAuth(\n                                    kind=\"PublicKey\"\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(ssh_target.id, role.id)\n\n        totp = pyotp.TOTP(otp_key_base32)\n\n        script = dedent(\n            f\"\"\"\n            set timeout {timeout - 5}\n\n            spawn ssh {user.username}:{ssh_target.name}@localhost -p {shared_wg.ssh_port} -o StrictHostKeychecking=no -o UserKnownHostsFile=/dev/null  -o IdentitiesOnly=yes -o IdentityFile=ssh-keys/id_ed25519 -o PreferredAuthentications=publickey,keyboard-interactive ls /bin/sh\n\n            expect \"Two-factor authentication\"\n            sleep 0.5\n            send \"{totp.now()}\\\\r\"\n\n            expect {{\n                \"/bin/sh\"  {{ exit 0; }}\n                eof {{ exit 1; }}\n            }}\n            \"\"\"\n        )\n\n        ssh_client = processes.start(\n            [\"expect\"],\n            stdin=subprocess.PIPE,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n        )\n\n        output, stderr = ssh_client.communicate(script.encode(), timeout=timeout)\n        assert ssh_client.returncode == 0, output + stderr\n\n        script = dedent(\n            f\"\"\"\n            set timeout {timeout - 5}\n\n            spawn ssh {user.username}:{ssh_target.name}@localhost -p {[shared_wg.ssh_port]} -o StrictHostKeychecking=no -o UserKnownHostsFile=/dev/null  -o IdentitiesOnly=yes -o IdentityFile=ssh-keys/id_ed25519 -o PreferredAuthentications=publickey,keyboard-interactive ls /bin/sh\n\n            expect \"Two-factor authentication\"\n            sleep 0.5\n            send \"12345678\\\\r\"\n\n            expect {{\n                \"/bin/sh\"  {{ exit 0; }}\n                \"Two-factor authentication\" {{ exit 1; }}\n                eof {{ exit 1; }}\n            }}\n            \"\"\"\n        )\n\n        ssh_client = processes.start(\n            [\"expect\"], stdin=subprocess.PIPE, stdout=subprocess.PIPE\n        )\n\n        output = ssh_client.communicate(script.encode(), timeout=timeout)[0]\n        assert ssh_client.returncode != 0, output\n"
  },
  {
    "path": "tests/test_ssh_user_auth_password.py",
    "content": "from pathlib import Path\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import ProcessManager, WarpgateProcess\nfrom .util import wait_port\n\n\nclass Test:\n    def test(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        ssh_port = processes.start_ssh_server(\n            trusted_keys=[wg_c_ed25519_pubkey.read_text()]\n        )\n\n        wait_port(ssh_port)\n\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(\n                sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n            )\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            ssh_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"ssh-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetSSHOptions(\n                            kind=\"Ssh\",\n                            host=\"localhost\",\n                            port=ssh_port,\n                            username=\"root\",\n                            auth=sdk.SSHTargetAuth(\n                                sdk.SSHTargetAuthSshTargetPublicKeyAuth(\n                                    kind=\"PublicKey\"\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(ssh_target.id, role.id)\n\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-v\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-i\",\n            \"/dev/null\",\n            \"-o\",\n            \"PreferredAuthentications=password\",\n            \"ls\",\n            \"/bin/sh\",\n            password=\"123\",\n        )\n        assert ssh_client.communicate(timeout=timeout)[0] == b\"/bin/sh\\n\"\n        assert ssh_client.returncode == 0\n\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-i\",\n            \"/dev/null\",\n            \"-o\",\n            \"PreferredAuthentications=password\",\n            \"ls\",\n            \"/bin/sh\",\n            password=\"321\",\n        )\n        ssh_client.communicate(timeout=timeout)\n        assert ssh_client.returncode != 0\n"
  },
  {
    "path": "tests/test_ssh_user_auth_pubkey.py",
    "content": "from pathlib import Path\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import ProcessManager, WarpgateProcess\nfrom .util import wait_port\n\n\nclass Test:\n    def test_ed25519(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        ssh_port = processes.start_ssh_server(\n            trusted_keys=[wg_c_ed25519_pubkey.read_text()]\n        )\n\n        wait_port(ssh_port)\n\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(\n                sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n            )\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_public_key_credential(\n                user.id,\n                sdk.NewPublicKeyCredential(\n                    label=\"Public Key\",\n                    openssh_public_key=open(\"ssh-keys/id_ed25519.pub\").read().strip()\n                ),\n            )\n            api.add_user_role(user.id, role.id)\n            ssh_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"ssh-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetSSHOptions(\n                            kind=\"Ssh\",\n                            host=\"localhost\",\n                            port=ssh_port,\n                            username=\"root\",\n                            auth=sdk.SSHTargetAuth(\n                                sdk.SSHTargetAuthSshTargetPublicKeyAuth(\n                                    kind=\"PublicKey\"\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(ssh_target.id, role.id)\n\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-o\",\n            \"IdentityFile=ssh-keys/id_ed25519\",\n            \"-o\",\n            \"PreferredAuthentications=publickey\",\n            # 'sh', '-c', '\"ls /bin/sh;sleep 1\"',\n            \"ls\",\n            \"/bin/sh\",\n        )\n        assert ssh_client.communicate(timeout=timeout)[0] == b\"/bin/sh\\n\"\n        assert ssh_client.returncode == 0\n\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-o\",\n            \"IdentityFile=ssh-keys/id_rsa\",\n            \"-o\",\n            \"PreferredAuthentications=publickey\",\n            \"ls\",\n            \"/bin/sh\",\n        )\n        assert ssh_client.communicate(timeout=timeout)[0] == b\"\"\n        assert ssh_client.returncode != 0\n\n    def test_rsa(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        ssh_port = processes.start_ssh_server(\n            trusted_keys=[wg_c_ed25519_pubkey.read_text()]\n        )\n\n        wait_port(ssh_port)\n\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(\n                sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n            )\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_public_key_credential(\n                user.id,\n                sdk.NewPublicKeyCredential(\n                    label=\"Public Key\",\n                    openssh_public_key=open(\"ssh-keys/id_rsa.pub\").read().strip()\n                ),\n            )\n            api.add_user_role(user.id, role.id)\n            ssh_target = api.create_target(sdk.TargetDataRequest(\n                name=f\"ssh-{uuid4()}\",\n                options=sdk.TargetOptions(\n                    sdk.TargetOptionsTargetSSHOptions(\n                        kind=\"Ssh\",\n                        host=\"localhost\",\n                        port=ssh_port,\n                        username=\"root\",\n                        auth=sdk.SSHTargetAuth(\n                            sdk.SSHTargetAuthSshTargetPublicKeyAuth(kind=\"PublicKey\")\n                        ),\n                    )\n                ),\n            ))\n            api.add_target_role(ssh_target.id, role.id)\n\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-v\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-o\",\n            \"IdentityFile=ssh-keys/id_rsa\",\n            \"-o\",\n            \"PreferredAuthentications=publickey\",\n            \"-o\",\n            \"PubkeyAcceptedKeyTypes=+ssh-rsa\",\n            \"ls\",\n            \"/bin/sh\",\n        )\n        assert ssh_client.communicate(timeout=timeout)[0] == b\"/bin/sh\\n\"\n        assert ssh_client.returncode == 0\n\n        ssh_client = processes.start_ssh_client(\n            f\"{user.username}:{ssh_target.name}@localhost\",\n            \"-p\",\n            str(shared_wg.ssh_port),\n            \"-o\",\n            \"IdentityFile=ssh-keys/id_ed25519\",\n            \"-o\",\n            \"PreferredAuthentications=publickey\",\n            \"-o\",\n            \"PubkeyAcceptedKeyTypes=+ssh-rsa\",\n            \"ls\",\n            \"/bin/sh\",\n        )\n        assert ssh_client.communicate(timeout=timeout)[0] == b\"\"\n        assert ssh_client.returncode != 0\n"
  },
  {
    "path": "tests/test_ssh_user_auth_ticket.py",
    "content": "from pathlib import Path\nfrom uuid import uuid4\n\nfrom .api_client import admin_client, sdk\nfrom .conftest import ProcessManager, WarpgateProcess\nfrom .util import wait_port\n\n\nclass Test:\n    def test(\n        self,\n        processes: ProcessManager,\n        wg_c_ed25519_pubkey: Path,\n        timeout,\n        shared_wg: WarpgateProcess,\n    ):\n        ssh_port = processes.start_ssh_server(\n            trusted_keys=[wg_c_ed25519_pubkey.read_text()]\n        )\n\n        wait_port(ssh_port)\n\n        url = f\"https://localhost:{shared_wg.http_port}\"\n        with admin_client(url) as api:\n            role = api.create_role(\n                sdk.RoleDataRequest(name=f\"role-{uuid4()}\"),\n            )\n            user = api.create_user(sdk.CreateUserRequest(username=f\"user-{uuid4()}\"))\n            api.create_password_credential(\n                user.id, sdk.NewPasswordCredential(password=\"123\")\n            )\n            api.add_user_role(user.id, role.id)\n            ssh_target = api.create_target(\n                sdk.TargetDataRequest(\n                    name=f\"ssh-{uuid4()}\",\n                    options=sdk.TargetOptions(\n                        sdk.TargetOptionsTargetSSHOptions(\n                            kind=\"Ssh\",\n                            host=\"localhost\",\n                            port=ssh_port,\n                            username=\"root\",\n                            auth=sdk.SSHTargetAuth(\n                                sdk.SSHTargetAuthSshTargetPublicKeyAuth(\n                                    kind=\"PublicKey\"\n                                )\n                            ),\n                        )\n                    ),\n                )\n            )\n            api.add_target_role(ssh_target.id, role.id)\n\n            secret = api.create_ticket(\n                sdk.CreateTicketRequest(\n                    target_name=ssh_target.name,\n                    username=user.username,\n                )\n            ).secret\n\n            ssh_client = processes.start_ssh_client(\n                f\"ticket-{secret}@localhost\",\n                \"-p\",\n                str(shared_wg.ssh_port),\n                \"-i\",\n                \"/dev/null\",\n                \"-o\",\n                \"PreferredAuthentications=password\",\n                \"ls\",\n                \"/bin/sh\",\n                password=\"123\",\n            )\n            assert ssh_client.communicate(timeout=timeout)[0] == b\"/bin/sh\\n\"\n            assert ssh_client.returncode == 0\n"
  },
  {
    "path": "tests/util.py",
    "content": "import logging\nimport os\nimport requests\nimport socket\nimport subprocess\nimport threading\nimport time\n\n\nlast_port = 1234\n\nmysql_client_ssl_opt = \"--ssl\"\nmysql_client_opts = []\nif \"GITHUB_ACTION\" in os.environ:\n    # Github uses MySQL instead of MariaDB\n    mysql_client_ssl_opt = \"--ssl-mode=REQUIRED\"\n    mysql_client_opts = [\"--enable-cleartext-plugin\"]\n\n\ndef alloc_port():\n    global last_port\n    last_port += 1\n    return last_port\n\n\ndef _wait_timeout(fn, msg, timeout=60):\n    t = threading.Thread(target=fn, daemon=True)\n    t.start()\n    t.join(timeout=timeout)\n    if t.is_alive():\n        raise Exception(msg)\n\n\ndef wait_port(port, recv=True, timeout=60, for_process: subprocess.Popen = None, connect_timeout=5, read_timeout=5):\n    logging.debug(f\"Waiting for port {port}\")\n\n    def wait():\n        while True:\n            try:\n                s = socket.create_connection((\"localhost\", port), timeout=connect_timeout)\n                if recv:\n                    s.settimeout(read_timeout)\n                    if not s.recv(100):\n                        raise Exception(\"Port is open but not responding\")\n                s.close()\n                logging.debug(f\"Port {port} is up\")\n                return\n            except socket.error:\n                if for_process:\n                    try:\n                        for_process.wait(timeout=0.1)\n                        raise Exception(\"Process exited while waiting for port\")\n                    except subprocess.TimeoutExpired:\n                        continue\n                else:\n                    time.sleep(0.1)\n\n    _wait_timeout(wait, f\"Port {port} is not up\", timeout=timeout)\n\n\ndef wait_mysql_port(port):\n    logging.debug(f\"Waiting for MySQL port {port}\")\n\n    def wait():\n        while True:\n            try:\n                subprocess.check_call(\n                    f'mysql --user=root --password=123 --host=127.0.0.1 --port={port} --execute=\"show schemas;\"',\n                    shell=True,\n                )\n                logging.debug(f\"Port {port} is up\")\n                break\n            except subprocess.CalledProcessError:\n                time.sleep(1)\n                continue\n\n    t = threading.Thread(target=wait, daemon=True)\n    t.start()\n    t.join(timeout=60)\n    if t.is_alive():\n        raise Exception(f\"Port {port} is not up\")\n\n\ndef create_ticket(url, username, target_name):\n    session = requests.Session()\n    session.verify = False\n    response = session.post(\n        f\"{url}/@warpgate/api/auth/login\",\n        json={\n            \"username\": \"admin\",\n            \"password\": \"123\",\n        },\n    )\n    assert response.status_code // 100 == 2\n    response = session.post(\n        f\"{url}/@warpgate/admin/api/tickets\",\n        json={\n            \"username\": username,\n            \"target_name\": target_name,\n        },\n    )\n    assert response.status_code == 201\n    return response.json()[\"secret\"]\n"
  },
  {
    "path": "warpgate/.gitignore",
    "content": "!Cargo.lock\n"
  },
  {
    "path": "warpgate/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate\"\nversion = \"0.22.0\"\n\n[dependencies]\nansi_term = { version = \"0.12\", default-features = false }\nanyhow.workspace = true\nasync-trait = { version = \"0.1\", default-features = false }\nbytes.workspace = true\nclap = { version = \"4.0\", features = [\"derive\", \"env\"], default-features = false }\nconfig = { version = \"0.15\", features = [\"yaml\"], default-features = false }\nconsole = { version = \"0.15\", default-features = false }\nconsole-subscriber = { version = \"0.4\", optional = true, default-features = false }\ndata-encoding.workspace = true\ndialoguer.workspace = true\nenum_dispatch.workspace = true\nfutures.workspace = true\nnotify = { version = \"8.0\", default-features = false, features = [\"fsevent-sys\"] }\nrcgen.workspace = true\nreqwest.workspace = true\nrustls.workspace = true\nserde_json.workspace = true\nserde_yaml = { version = \"0.9\", default-features = false }\nsea-orm.workspace = true\ntime = { version = \"0.3\", default-features = false }\ntokio.workspace = true\ntracing.workspace = true\ntracing-log = { version = \"0.2\" }\ntracing-subscriber = { version = \"0.3\", features = [\n    \"ansi\",\n    \"env-filter\",\n    \"local-time\",\n], default-features = false }\nuuid = { version = \"1.3\", default-features = false }\nwarpgate-admin = { version = \"*\", path = \"../warpgate-admin\" }\nwarpgate-common = { version = \"*\", path = \"../warpgate-common\" }\nwarpgate-ca = { version = \"*\", path = \"../warpgate-ca\" }\nwarpgate-core = { version = \"*\", path = \"../warpgate-core\" }\nwarpgate-db-entities = { version = \"*\", path = \"../warpgate-db-entities\" }\nwarpgate-protocol-http = { version = \"*\", path = \"../warpgate-protocol-http\" }\nwarpgate-protocol-mysql = { version = \"*\", path = \"../warpgate-protocol-mysql\" }\nwarpgate-protocol-postgres = { version = \"*\", path = \"../warpgate-protocol-postgres\" }\nwarpgate-protocol-ssh = { version = \"*\", path = \"../warpgate-protocol-ssh\" }\nwarpgate-protocol-kubernetes = { version = \"*\", path = \"../warpgate-protocol-kubernetes\" }\nwarpgate-tls = { version = \"*\", path = \"../warpgate-tls\" }\nschemars.workspace = true\n\n[target.'cfg(target_os = \"linux\")'.dependencies]\nsd-notify = { version = \"0.4\", default-features = false }\n\n[features]\ndefault = [\"sqlite\"]\ntokio-console = [\"dep:console-subscriber\", \"tokio/tracing\"]\npostgres = [\"warpgate-core/postgres\"]\nmysql = [\"warpgate-core/mysql\"]\nsqlite = [\"warpgate-core/sqlite\"]\n"
  },
  {
    "path": "warpgate/src/commands/check.rs",
    "content": "use anyhow::{Context, Result};\nuse tracing::*;\nuse warpgate_common::GlobalParams;\nuse warpgate_tls::{TlsCertificateBundle, TlsPrivateKey};\n\nuse crate::config::load_config;\n\npub(crate) async fn command(params: &GlobalParams) -> Result<()> {\n    let config = load_config(params, true)?;\n    TlsCertificateBundle::from_file(\n        params\n            .paths_relative_to()\n            .join(&config.store.http.certificate),\n    )\n    .await\n    .with_context(|| \"Checking HTTPS certificate\".to_string())?;\n    TlsPrivateKey::from_file(params.paths_relative_to().join(&config.store.http.key))\n        .await\n        .with_context(|| \"Checking HTTPS key\".to_string())?;\n    if config.store.mysql.enable {\n        TlsCertificateBundle::from_file(\n            params\n                .paths_relative_to()\n                .join(&config.store.mysql.certificate),\n        )\n        .await\n        .with_context(|| \"Checking MySQL certificate\".to_string())?;\n        TlsPrivateKey::from_file(params.paths_relative_to().join(&config.store.mysql.key))\n            .await\n            .with_context(|| \"Checking MySQL key\".to_string())?;\n    }\n    if config.store.postgres.enable {\n        TlsCertificateBundle::from_file(\n            params\n                .paths_relative_to()\n                .join(&config.store.postgres.certificate),\n        )\n        .await\n        .with_context(|| \"Checking PostgreSQL certificate\".to_string())?;\n        TlsPrivateKey::from_file(params.paths_relative_to().join(&config.store.postgres.key))\n            .await\n            .with_context(|| \"Checking PostgreSQL key\".to_string())?;\n    }\n    info!(\"No problems found\");\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate/src/commands/client_keys.rs",
    "content": "use anyhow::Result;\nuse warpgate_common::GlobalParams;\n\nuse crate::config::load_config;\n\npub(crate) async fn command(params: &GlobalParams) -> Result<()> {\n    let config = load_config(params, true)?;\n    let keys = warpgate_protocol_ssh::load_keys(&config, params, \"client\")?;\n    println!(\"Warpgate SSH client keys:\");\n    println!(\"(add these to your target's authorized_keys file)\");\n    println!();\n    for key in keys {\n        println!(\"{}\", key.public_key().to_openssh()?);\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate/src/commands/common.rs",
    "content": "use std::io::IsTerminal;\n\nuse tracing::*;\n\npub(crate) fn assert_interactive_terminal() {\n    if !std::io::stdin().is_terminal() {\n        error!(\"Please run this command from an interactive terminal.\");\n        if is_docker() {\n            info!(\"(have you forgotten `-it`?)\");\n        }\n        std::process::exit(1);\n    }\n}\n\npub(crate) fn is_docker() -> bool {\n    std::env::var(\"DOCKER\").is_ok()\n}\n"
  },
  {
    "path": "warpgate/src/commands/create_user.rs",
    "content": "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};\nuse uuid::Uuid;\nuse warpgate_common::{\n    GlobalParams, Secret, UserPasswordCredential, UserRequireCredentialsPolicy, WarpgateError,\n};\nuse warpgate_core::Services;\nuse warpgate_db_entities::{\n    AdminRole, PasswordCredential, Role, User, UserAdminRoleAssignment, UserRoleAssignment,\n};\n\nuse crate::config::load_config;\n\npub(crate) async fn command(\n    params: &GlobalParams,\n    username: &str,\n    password: &Secret<String>,\n    role: &Option<String>,\n) -> anyhow::Result<()> {\n    let config = load_config(params, true)?;\n    let services = Services::new(config.clone(), None, params.clone()).await?;\n\n    let db = services.db.lock().await;\n\n    let db_user = match User::Entity::find()\n        .filter(User::Column::Username.eq(username))\n        .all(&*db)\n        .await?\n        .first()\n    {\n        Some(x) => x.to_owned(),\n        None => {\n            let values = User::ActiveModel {\n                id: Set(Uuid::new_v4()),\n                username: Set(username.to_owned()),\n                description: Set(\"\".into()),\n                credential_policy: Set(serde_json::to_value(None::<UserRequireCredentialsPolicy>)?),\n                rate_limit_bytes_per_second: Set(None),\n                ldap_server_id: Set(None),\n                ldap_object_uuid: Set(None),\n            };\n            values.insert(&*db).await.map_err(WarpgateError::from)?\n        }\n    };\n\n    PasswordCredential::ActiveModel {\n        user_id: Set(db_user.id),\n        id: Set(Uuid::new_v4()),\n        ..UserPasswordCredential::from_password(password).into()\n    }\n    .insert(&*db)\n    .await?;\n\n    if let Some(role_name) = role {\n        // try regular role first\n        if let Some(db_role) = Role::Entity::find()\n            .filter(Role::Column::Name.eq(role_name.clone()))\n            .one(&*db)\n            .await?\n        {\n            if UserRoleAssignment::Entity::find()\n                .filter(UserRoleAssignment::Column::UserId.eq(db_user.id))\n                .filter(UserRoleAssignment::Column::RoleId.eq(db_role.id))\n                .all(&*db)\n                .await?\n                .is_empty()\n            {\n                let values = UserRoleAssignment::ActiveModel {\n                    user_id: Set(db_user.id),\n                    role_id: Set(db_role.id),\n                    ..Default::default()\n                };\n                values.insert(&*db).await.map_err(WarpgateError::from)?;\n            }\n        }\n\n        // admin role\n        if let Some(db_admin) = AdminRole::Entity::find()\n            .filter(AdminRole::Column::Name.eq(role_name.clone()))\n            .one(&*db)\n            .await?\n        {\n            if UserAdminRoleAssignment::Entity::find()\n                .filter(UserAdminRoleAssignment::Column::UserId.eq(db_user.id))\n                .filter(UserAdminRoleAssignment::Column::AdminRoleId.eq(db_admin.id))\n                .all(&*db)\n                .await?\n                .is_empty()\n            {\n                let values = UserAdminRoleAssignment::ActiveModel {\n                    user_id: Set(db_user.id),\n                    admin_role_id: Set(db_admin.id),\n                    ..Default::default()\n                };\n                values.insert(&*db).await.map_err(WarpgateError::from)?;\n            }\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate/src/commands/healthcheck.rs",
    "content": "use anyhow::{Context, Result};\nuse tokio::time::timeout;\nuse warpgate_common::GlobalParams;\n\nuse crate::config::load_config;\n\npub(crate) async fn command(params: &GlobalParams) -> Result<()> {\n    let config = load_config(params, true)?;\n\n    let url = format!(\n        \"https://{}/@warpgate/api/info\",\n        config.store.http.listen.address()\n    );\n\n    let client = reqwest::Client::builder()\n        .danger_accept_invalid_certs(true)\n        .use_rustls_tls()\n        .build()?;\n\n    let response = timeout(std::time::Duration::from_secs(5), client.get(&url).send())\n        .await\n        .context(\"Timeout\")?\n        .context(\"Failed to send request\")?;\n\n    response.error_for_status()?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate/src/commands/mod.rs",
    "content": "pub mod check;\npub mod client_keys;\nmod common;\npub mod create_user;\npub mod healthcheck;\npub mod recover_access;\npub mod run;\npub mod setup;\n"
  },
  {
    "path": "warpgate/src/commands/recover_access.rs",
    "content": "use anyhow::Result;\nuse dialoguer::theme::ColorfulTheme;\nuse sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::auth::CredentialKind;\nuse warpgate_common::{GlobalParams, Secret, User as UserConfig, UserPasswordCredential};\nuse warpgate_core::Services;\nuse warpgate_db_entities::{PasswordCredential, User};\n\nuse crate::commands::common::assert_interactive_terminal;\nuse crate::config::load_config;\n\npub(crate) async fn command(params: &GlobalParams, username: &Option<String>) -> Result<()> {\n    assert_interactive_terminal();\n\n    let config = load_config(params, true)?;\n    let services = Services::new(config.clone(), None, params.clone()).await?;\n    warpgate_protocol_ssh::generate_keys(&config, params, \"host\")?;\n    warpgate_protocol_ssh::generate_keys(&config, params, \"client\")?;\n\n    let theme = ColorfulTheme::default();\n    let db = services.db.lock().await;\n\n    let users = User::Entity::find()\n        .order_by_asc(User::Column::Username)\n        .all(&*db)\n        .await?;\n\n    let users: Result<Vec<UserConfig>, _> = users.into_iter().map(|t| t.try_into()).collect();\n    let mut users = users?;\n    let usernames = users.iter().map(|x| x.username.clone()).collect::<Vec<_>>();\n\n    let user = match username {\n        Some(username) => users\n            .iter_mut()\n            .find(|x| &x.username == username)\n            .ok_or_else(|| anyhow::anyhow!(\"User not found\"))?,\n        None =>\n        {\n            #[allow(clippy::indexing_slicing)]\n            &mut users[dialoguer::Select::with_theme(&theme)\n                .with_prompt(\"Select a user to recover access for\")\n                .items(&usernames)\n                .default(0)\n                .interact()?]\n        }\n    };\n\n    let password = Secret::new(\n        dialoguer::Password::with_theme(&theme)\n            .with_prompt(format!(\"New password for {}\", user.username))\n            .interact()?,\n    );\n\n    if !dialoguer::Confirm::with_theme(&theme)\n            .default(true)\n            .with_prompt(\"This tool will add a new password for the user and set their HTTP auth policy to only require a password. Continue?\")\n            .interact()? {\n                std::process::exit(0);\n            }\n\n    PasswordCredential::ActiveModel {\n        user_id: Set(user.id),\n        id: Set(Uuid::new_v4()),\n        ..UserPasswordCredential::from_password(&password).into()\n    }\n    .insert(&*db)\n    .await?;\n\n    user.credential_policy\n        .get_or_insert_with(Default::default)\n        .http = Some(vec![CredentialKind::Password]);\n\n    User::ActiveModel {\n        id: Set(user.id),\n        credential_policy: Set(serde_json::to_value(Some(&user.credential_policy))?),\n        ..Default::default()\n    }\n    .update(&*db)\n    .await?;\n\n    info!(\"All done. You can now log in\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate/src/commands/run.rs",
    "content": "use anyhow::{Context, Result};\nuse futures::{FutureExt, StreamExt};\n#[cfg(target_os = \"linux\")]\nuse sd_notify::NotifyState;\nuse tokio::signal::unix::SignalKind;\nuse tracing::*;\nuse warpgate_common::version::warpgate_version;\nuse warpgate_common::{GlobalParams, ListenEndpoint};\nuse warpgate_core::db::cleanup_db;\nuse warpgate_core::logging::install_database_logger;\nuse warpgate_core::{ConfigProvider, ProtocolServer, Services};\nuse warpgate_protocol_http::HTTPProtocolServer;\nuse warpgate_protocol_kubernetes::KubernetesProtocolServer;\nuse warpgate_protocol_mysql::MySQLProtocolServer;\nuse warpgate_protocol_postgres::PostgresProtocolServer;\nuse warpgate_protocol_ssh::SSHProtocolServer;\n\nuse crate::config::{load_config, watch_config};\n\nasync fn run_protocol_server<T: ProtocolServer + Send + 'static>(\n    server: T,\n    address: ListenEndpoint,\n) -> Result<()> {\n    let name = server.name();\n    info!(\"Accepting {name} connections on {address:?}\");\n    server\n        .run(address)\n        .await\n        .with_context(|| format!(\"protocol server: {name}\"))\n}\n\npub(crate) async fn command(params: &GlobalParams, enable_admin_token: bool) -> Result<()> {\n    let version = warpgate_version();\n    info!(%version, \"Warpgate\");\n\n    let admin_token = enable_admin_token.then(|| {\n        std::env::var(\"WARPGATE_ADMIN_TOKEN\").unwrap_or_else(|_| {\n            error!(\"`WARPGATE_ADMIN_TOKEN` env variable must set when using --enable-admin-token\");\n            std::process::exit(1);\n        })\n    });\n\n    let config = match load_config(params, true) {\n        Ok(config) => config,\n        Err(error) => {\n            error!(?error, \"Failed to load config file\");\n            std::process::exit(1);\n        }\n    };\n\n    let services = Services::new(config.clone(), admin_token, params.clone()).await?;\n\n    install_database_logger(services.db.clone());\n\n    if console::user_attended() {\n        info!(\"--------------------------------------------\");\n        info!(\"Warpgate is now running.\");\n    }\n\n    let mut protocol_futures = futures::stream::FuturesUnordered::new();\n\n    protocol_futures.push(\n        run_protocol_server(\n            HTTPProtocolServer::new(&services).await?,\n            config.store.http.listen.clone(),\n        )\n        .boxed(),\n    );\n\n    if config.store.ssh.enable {\n        protocol_futures.push(\n            run_protocol_server(\n                SSHProtocolServer::new(&services).await?,\n                config.store.ssh.listen.clone(),\n            )\n            .boxed(),\n        );\n    }\n\n    if config.store.mysql.enable {\n        protocol_futures.push(\n            run_protocol_server(\n                MySQLProtocolServer::new(&services).await?,\n                config.store.mysql.listen.clone(),\n            )\n            .boxed(),\n        );\n    }\n\n    if config.store.postgres.enable {\n        protocol_futures.push(\n            run_protocol_server(\n                PostgresProtocolServer::new(&services).await?,\n                config.store.postgres.listen.clone(),\n            )\n            .boxed(),\n        );\n    }\n\n    if config.store.kubernetes.enable {\n        protocol_futures.push(\n            KubernetesProtocolServer::new(&services)\n                .await?\n                .run(config.store.kubernetes.listen.clone())\n                .boxed(),\n        );\n    }\n\n    tokio::spawn({\n        let services = services.clone();\n        async move {\n            loop {\n                let retention = { services.config.lock().await.store.log.retention };\n                let interval = retention / 10;\n                #[allow(clippy::explicit_auto_deref)]\n                match cleanup_db(\n                    &mut *services.db.lock().await,\n                    &mut *services.recordings.lock().await,\n                    &retention,\n                )\n                .await\n                {\n                    Err(error) => error!(?error, \"Failed to cleanup the database\"),\n                    Ok(_) => debug!(\"Database cleaned up, next in {:?}\", interval),\n                }\n                tokio::time::sleep(interval).await;\n            }\n        }\n    });\n\n    #[cfg(target_os = \"linux\")]\n    if let Ok(true) = sd_notify::booted() {\n        use std::time::Duration;\n        tokio::spawn(async {\n            if let Err(error) = async {\n                sd_notify::notify(false, &[NotifyState::Ready])?;\n                loop {\n                    sd_notify::notify(false, &[NotifyState::Watchdog])?;\n                    tokio::time::sleep(Duration::from_secs(15)).await;\n                }\n                #[allow(unreachable_code)]\n                Ok::<(), anyhow::Error>(())\n            }\n            .await\n            {\n                error!(?error, \"Failed to communicate with systemd\");\n            }\n        });\n    }\n\n    drop(config);\n\n    if protocol_futures.is_empty() {\n        anyhow::bail!(\"No protocols are enabled in the config file, exiting\");\n    }\n\n    tokio::spawn(watch_config_and_reload(services.clone()));\n\n    let mut sigint = tokio::signal::unix::signal(SignalKind::interrupt())?;\n\n    loop {\n        tokio::select! {\n            _ = tokio::signal::ctrl_c() => {\n                std::process::exit(1);\n            }\n            _ = sigint.recv() => {\n                break\n            }\n            result = protocol_futures.next() => {\n                match result {\n                    Some(Err(error)) => {\n                        error!(?error, \"Server error\");\n                        std::process::exit(1);\n                    },\n                    None => break,\n                    _ => (),\n                }\n            }\n        }\n    }\n\n    info!(\"Exiting\");\n    Ok(())\n}\n\npub async fn watch_config_and_reload(services: Services) -> Result<()> {\n    let mut reload_event = watch_config(&services.global_params, services.config.clone())?;\n\n    while let Ok(()) = reload_event.recv().await {\n        let state = services.state.lock().await;\n        let mut cp = services.config_provider.lock().await;\n        // TODO no longer happens since everything is in the DB\n        for (id, session) in state.sessions.iter() {\n            let mut session = session.lock().await;\n            if let (Some(user_info), Some(target)) =\n                (session.user_info.as_ref(), session.target.as_ref())\n            {\n                if !cp\n                    .authorize_target(&user_info.username, &target.name)\n                    .await?\n                {\n                    warn!(sesson_id=%id, %user_info.username, target=&target.name, \"Session no longer authorized after config reload\");\n                    session.handle.close();\n                }\n            }\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate/src/commands/setup.rs",
    "content": "#![allow(clippy::collapsible_else_if)]\n\nuse std::fs::{create_dir_all, File};\nuse std::io::Write;\nuse std::net::{Ipv6Addr, SocketAddr, ToSocketAddrs};\nuse std::path::{Path, PathBuf};\n\nuse anyhow::{Context, Result};\nuse dialoguer::theme::ColorfulTheme;\nuse rcgen::generate_simple_self_signed;\nuse tracing::*;\nuse warpgate_common::helpers::fs::{secure_directory, secure_file};\nuse warpgate_common::version::warpgate_version;\nuse warpgate_common::{\n    GlobalParams, HttpConfig, KubernetesConfig, ListenEndpoint, MySqlConfig, PostgresConfig,\n    Secret, SshConfig, WarpgateConfigStore,\n};\nuse warpgate_core::consts::{BUILTIN_ADMIN_ROLE_NAME, BUILTIN_ADMIN_USERNAME};\n\nuse crate::commands::common::{assert_interactive_terminal, is_docker};\nuse crate::config::load_config;\nuse crate::{Cli, Commands};\n\nfn prompt_endpoint(prompt: &str, default: ListenEndpoint) -> ListenEndpoint {\n    loop {\n        let v = dialoguer::Input::with_theme(&ColorfulTheme::default())\n            .default(format!(\"{default:?}\"))\n            .with_prompt(prompt)\n            .interact_text()\n            .context(\"dialoguer\")\n            .and_then(|v| v.to_socket_addrs().context(\"address resolution\"));\n        match v {\n            Ok(mut addr) => match addr.next() {\n                Some(addr) => return ListenEndpoint::from(addr),\n                None => {\n                    error!(\"No endpoints resolved\");\n                }\n            },\n            Err(err) => {\n                error!(\"Failed to resolve this endpoint: {err}\")\n            }\n        }\n    }\n}\n\npub(crate) async fn command(cli: &Cli, params: &GlobalParams) -> Result<()> {\n    let version = warpgate_version();\n    info!(\"Welcome to Warpgate {version}\");\n\n    if cli.config.exists() {\n        error!(\"Config file already exists at {}.\", cli.config.display());\n        error!(\"To generate a new config file, rename or delete the existing one first.\");\n        std::process::exit(1);\n    }\n\n    if let Commands::Setup { .. } = cli.command {\n        assert_interactive_terminal();\n    }\n\n    let mut config_dir = cli.config.parent().unwrap_or_else(|| Path::new(&\".\"));\n    if config_dir.as_os_str().is_empty() {\n        config_dir = Path::new(&\".\");\n    }\n    create_dir_all(config_dir)?;\n\n    info!(\"Let's do some basic setup first.\");\n    info!(\n        \"The new config will be written in {}.\",\n        cli.config.display()\n    );\n\n    let theme = ColorfulTheme::default();\n    let mut store = WarpgateConfigStore::default();\n\n    // ---\n\n    if !is_docker() {\n        info!(\n            \"* Paths can be either absolute or relative to {}.\",\n            config_dir.canonicalize()?.display()\n        );\n    }\n\n    // ---\n\n    let data_path: String = if let Commands::UnattendedSetup { data_path, .. } = &cli.command {\n        data_path.to_owned()\n    } else {\n        #[cfg(target_os = \"linux\")]\n        let default_data_path = \"/var/lib/warpgate\".to_string();\n        #[cfg(target_os = \"macos\")]\n        let default_data_path = \"/usr/local/var/lib/warpgate\".to_string();\n\n        if is_docker() {\n            \"/data\".to_owned()\n        } else {\n            dialoguer::Input::with_theme(&theme)\n                .default(default_data_path)\n                .with_prompt(\"Directory to store app data (up to a few MB) in\")\n                .interact_text()?\n        }\n    };\n\n    let data_path = config_dir.join(PathBuf::from(&data_path)).canonicalize()?;\n    create_dir_all(&data_path)?;\n\n    let db_path = data_path.join(\"db\");\n    create_dir_all(&db_path)?;\n    if params.should_secure_files() {\n        secure_directory(&db_path)?;\n    }\n\n    store.database_url = Secret::new(match &cli.command {\n        Commands::UnattendedSetup {\n            database_url: Some(url),\n            ..\n        }\n        | Commands::Setup {\n            database_url: Some(url),\n            ..\n        } => url.to_owned(),\n        _ => {\n            let mut db_path = db_path.to_string_lossy().to_string();\n\n            if let Some(x) = db_path.strip_suffix(\"./\") {\n                db_path = x.to_string();\n            }\n\n            format!(\"sqlite:{db_path}\")\n        }\n    });\n\n    if let Commands::UnattendedSetup { http_port, .. } = &cli.command {\n        store.http.listen =\n            ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), *http_port));\n    } else {\n        if !is_docker() {\n            store.http.listen = prompt_endpoint(\n                \"Endpoint to listen for HTTP connections on\",\n                HttpConfig::default().listen,\n            );\n        }\n    }\n\n    if let Commands::UnattendedSetup { ssh_port, .. } = &cli.command {\n        if let Some(ssh_port) = ssh_port {\n            store.ssh.enable = true;\n            store.ssh.listen =\n                ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), *ssh_port));\n        }\n    } else {\n        if is_docker() {\n            store.ssh.enable = true;\n        } else {\n            info!(\"You will now choose specific protocol listeners to be enabled.\");\n            info!(\"\");\n            info!(\"NB: Nothing will be exposed by default -\");\n            info!(\"    you'll choose target hosts in the UI later.\");\n\n            store.ssh.enable = dialoguer::Confirm::with_theme(&theme)\n                .default(true)\n                .with_prompt(\"Accept SSH connections?\")\n                .interact()?;\n\n            if store.ssh.enable {\n                store.ssh.listen = prompt_endpoint(\n                    \"Endpoint to listen for SSH connections on\",\n                    SshConfig::default().listen,\n                );\n            }\n        }\n    }\n\n    if let Commands::UnattendedSetup { mysql_port, .. } = &cli.command {\n        if let Some(mysql_port) = mysql_port {\n            store.mysql.enable = true;\n            store.mysql.listen =\n                ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), *mysql_port));\n        }\n    } else {\n        if is_docker() {\n            store.mysql.enable = true;\n        } else {\n            store.mysql.enable = dialoguer::Confirm::with_theme(&theme)\n                .default(true)\n                .with_prompt(\"Accept MySQL connections?\")\n                .interact()?;\n\n            if store.mysql.enable {\n                store.mysql.listen = prompt_endpoint(\n                    \"Endpoint to listen for MySQL connections on\",\n                    MySqlConfig::default().listen,\n                );\n            }\n        }\n    }\n\n    if let Commands::UnattendedSetup { postgres_port, .. } = &cli.command {\n        if let Some(postgres_port) = postgres_port {\n            store.postgres.enable = true;\n            store.postgres.listen = ListenEndpoint::from(SocketAddr::new(\n                Ipv6Addr::UNSPECIFIED.into(),\n                *postgres_port,\n            ));\n        }\n    } else {\n        if is_docker() {\n            store.postgres.enable = true;\n        } else {\n            store.postgres.enable = dialoguer::Confirm::with_theme(&theme)\n                .default(true)\n                .with_prompt(\"Accept PostgreSQL connections?\")\n                .interact()?;\n\n            if store.postgres.enable {\n                store.postgres.listen = prompt_endpoint(\n                    \"Endpoint to listen for PostgreSQL connections on\",\n                    PostgresConfig::default().listen,\n                );\n            }\n        }\n    }\n\n    if let Commands::UnattendedSetup {\n        kubernetes_port, ..\n    } = &cli.command\n    {\n        if let Some(kubernetes_port) = kubernetes_port {\n            store.kubernetes.enable = true;\n            store.kubernetes.listen = ListenEndpoint::from(SocketAddr::new(\n                Ipv6Addr::UNSPECIFIED.into(),\n                *kubernetes_port,\n            ));\n        }\n    } else {\n        if is_docker() {\n            store.kubernetes.enable = true;\n        } else {\n            store.kubernetes.enable = dialoguer::Confirm::with_theme(&theme)\n                .default(true)\n                .with_prompt(\"Accept Kubernetes connections?\")\n                .interact()?;\n\n            if store.kubernetes.enable {\n                store.kubernetes.listen = prompt_endpoint(\n                    \"Endpoint to listen for Kubernetes connections on\",\n                    KubernetesConfig::default().listen,\n                );\n            }\n        }\n    }\n\n    store.http.certificate = data_path\n        .join(\"tls.certificate.pem\")\n        .to_string_lossy()\n        .to_string();\n\n    store.http.key = data_path.join(\"tls.key.pem\").to_string_lossy().to_string();\n\n    store.mysql.certificate = store.http.certificate.clone();\n    store.mysql.key = store.http.key.clone();\n\n    store.postgres.certificate = store.http.certificate.clone();\n    store.postgres.key = store.http.key.clone();\n\n    store.kubernetes.certificate = store.http.certificate.clone();\n    store.kubernetes.key = store.http.key.clone();\n\n    // ---\n\n    store.ssh.keys = data_path.join(\"ssh-keys\").to_string_lossy().to_string();\n\n    // ---\n\n    if let Commands::UnattendedSetup {\n        record_sessions, ..\n    } = &cli.command\n    {\n        store.recordings.enable = *record_sessions;\n    } else {\n        store.recordings.enable = dialoguer::Confirm::with_theme(&theme)\n            .default(true)\n            .with_prompt(\"Do you want to record user sessions?\")\n            .interact()?;\n    }\n    store.recordings.path = data_path.join(\"recordings\").to_string_lossy().to_string();\n\n    // ---\n\n    let admin_password = Secret::new(\n        if let Commands::UnattendedSetup { admin_password, .. } = &cli.command {\n            if let Some(admin_password) = admin_password {\n                admin_password.to_owned()\n            } else {\n                if let Ok(admin_password) = std::env::var(\"WARPGATE_ADMIN_PASSWORD\") {\n                    admin_password\n                } else {\n                    error!(\n                    \"You must supply the admin password either through the --admin-password option\"\n                );\n                    error!(\"or the WARPGATE_ADMIN_PASSWORD environment variable.\");\n                    std::process::exit(1);\n                }\n            }\n        } else {\n            dialoguer::Password::with_theme(&theme)\n                .with_prompt(\"Set a password for the Warpgate admin user\")\n                .interact()?\n        },\n    );\n\n    if let Commands::UnattendedSetup { external_host, .. } = &cli.command {\n        store.external_host = external_host.clone();\n    }\n\n    // ---\n\n    info!(\"Generated configuration:\");\n    let yaml = serde_yaml::to_string(&store)?;\n    println!(\"{yaml}\");\n\n    let yaml = format!(\n        \"# Config generated in version {version}\\n# yaml-language-server: $schema=https://raw.githubusercontent.com/warp-tech/warpgate/refs/heads/main/config-schema.json\\n\\n{yaml}\",\n        version = warpgate_version()\n    );\n\n    File::create(&cli.config)?.write_all(yaml.as_bytes())?;\n    info!(\"Saved into {}\", cli.config.display());\n\n    let config = load_config(params, true)?;\n    warpgate_protocol_ssh::generate_keys(&config, params, \"host\")?;\n    warpgate_protocol_ssh::generate_keys(&config, params, \"client\")?;\n\n    // Create the admin user\n    crate::commands::create_user::command(\n        params,\n        BUILTIN_ADMIN_USERNAME,\n        &admin_password,\n        &Some(BUILTIN_ADMIN_ROLE_NAME.to_string()),\n    )\n    .await?;\n\n    {\n        info!(\"Generating a TLS certificate\");\n        let cert = generate_simple_self_signed(vec![\n            \"warpgate.local\".to_string(),\n            \"localhost\".to_string(),\n        ])?;\n\n        let certificate_path = params\n            .paths_relative_to()\n            .join(&config.store.http.certificate);\n        let key_path = params.paths_relative_to().join(&config.store.http.key);\n        std::fs::write(&certificate_path, cert.cert.pem())?;\n        std::fs::write(&key_path, cert.key_pair.serialize_pem())?;\n        if params.should_secure_files() {\n            secure_file(&certificate_path)?;\n            secure_file(&key_path)?;\n        }\n    }\n\n    info!(\"\");\n    info!(\"Admin user credentials:\");\n    info!(\"  * Username: admin\");\n    info!(\"  * Password: <your password>\");\n    info!(\"\");\n    info!(\"You can now start Warpgate with:\");\n    if is_docker() {\n        info!(\"docker run -p 8888:8888 -p 2222:2222 -it -v <your data dir>:/data ghcr.io/warp-tech/warpgate\");\n    } else {\n        info!(\n            \"  {} --config {} run\",\n            std::env::args()\n                .next()\n                .unwrap_or_else(|| \"warpgate\".to_string()),\n            cli.config.display()\n        );\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate/src/config.rs",
    "content": "use std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse config::{Config, Environment, File, FileFormat};\nuse notify::{recommended_watcher, RecursiveMode, Watcher};\nuse tokio::sync::{broadcast, mpsc, Mutex};\nuse tracing::*;\nuse warpgate_common::helpers::fs::secure_file;\nuse warpgate_common::{GlobalParams, WarpgateConfig, WarpgateConfigStore};\n\npub fn load_config(params: &GlobalParams, secure: bool) -> Result<WarpgateConfig> {\n    let mut store: serde_yaml::Value = Config::builder()\n        .add_source(File::new(\n            params\n                .config_path()\n                .to_str()\n                .context(\"Invalid config path\")?,\n            FileFormat::Yaml,\n        ))\n        .add_source(Environment::with_prefix(\"WARPGATE\"))\n        .build()\n        .context(\"Could not load config\")?\n        .try_deserialize()\n        .context(\"Could not parse YAML\")?;\n\n    if secure && params.should_secure_files() {\n        secure_file(params.config_path()).context(\"Could not secure config\")?;\n    }\n\n    check_and_migrate_config(&mut store);\n\n    let store: WarpgateConfigStore =\n        serde_yaml::from_value(store).context(\"Could not load config\")?;\n\n    let config = WarpgateConfig { store };\n\n    info!(\"Using config: {:?}\", params.config_path());\n    config.validate();\n    Ok(config)\n}\n\nfn check_and_migrate_config(store: &mut serde_yaml::Value) {\n    use serde_yaml::Value;\n    if let Some(map) = store.as_mapping_mut() {\n        if let Some(web_admin) = map.remove(Value::String(\"web_admin\".into())) {\n            warn!(\"The `web_admin` config section is deprecated. Rename it to `http`.\");\n            map.insert(Value::String(\"http\".into()), web_admin);\n        }\n\n        if let Some(Value::Sequence(ref mut users)) = map.get_mut(Value::String(\"users\".into())) {\n            for user in users {\n                if let Value::Mapping(ref mut user) = user {\n                    if let Some(new_require) = match user.get(Value::String(\"require\".into())) {\n                        Some(Value::Sequence(ref old_requires)) => Some(Value::Mapping(\n                            vec![\n                                (\n                                    Value::String(\"ssh\".into()),\n                                    Value::Sequence(old_requires.clone()),\n                                ),\n                                (\n                                    Value::String(\"http\".into()),\n                                    Value::Sequence(old_requires.clone()),\n                                ),\n                            ]\n                            .into_iter()\n                            .collect(),\n                        )),\n                        x => x.cloned(),\n                    } {\n                        user.insert(Value::String(\"require\".into()), new_require);\n                    }\n                }\n            }\n        }\n    }\n}\n\npub fn watch_config(\n    params: &GlobalParams,\n    config: Arc<Mutex<WarpgateConfig>>,\n) -> Result<broadcast::Receiver<()>> {\n    let params = params.clone();\n\n    let (tx, mut rx) = mpsc::channel(16);\n    let mut watcher = recommended_watcher(move |res| {\n        let _ = tx.blocking_send(res);\n    })?;\n    watcher.watch(params.config_path().as_ref(), RecursiveMode::NonRecursive)?;\n\n    let (tx2, rx2) = broadcast::channel(16);\n    tokio::spawn(async move {\n        let _watcher = watcher; // avoid dropping the watcher\n        loop {\n            match rx.recv().await {\n                Some(Ok(event)) => {\n                    if event.kind.is_modify() {\n                        match load_config(&params, false) {\n                            Ok(new_config) => {\n                                *(config.lock().await) = new_config;\n                                let _ = tx2.send(());\n                                info!(\"Reloaded config\");\n                            }\n                            Err(error) => error!(?error, \"Failed to reload config\"),\n                        }\n                    }\n                }\n                Some(Err(error)) => error!(?error, \"Failed to watch config\"),\n                None => {\n                    error!(\"Config watch failed\");\n                    break;\n                }\n            }\n        }\n\n        #[allow(unreachable_code)]\n        Ok::<_, anyhow::Error>(())\n    });\n\n    Ok(rx2)\n}\n"
  },
  {
    "path": "warpgate/src/logging.rs",
    "content": "use std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse time::{format_description, UtcOffset};\nuse tracing_log::LogTracer;\nuse tracing_subscriber::filter::dynamic_filter_fn;\nuse tracing_subscriber::fmt::time::OffsetTime;\nuse tracing_subscriber::layer::SubscriberExt;\nuse tracing_subscriber::util::SubscriberInitExt;\nuse tracing_subscriber::{EnvFilter, Layer};\nuse warpgate_common::{LogFormat, WarpgateConfig};\nuse warpgate_core::logging::{\n    make_database_logger_layer, make_json_console_logger_layer, make_socket_logger_layer,\n};\n\nuse crate::Cli;\n\npub async fn init_logging(config: Option<&WarpgateConfig>, cli: &Cli) -> Result<()> {\n    if std::env::var(\"RUST_LOG\").is_err() {\n        match cli.debug {\n            0 => std::env::set_var(\"RUST_LOG\", \"warpgate=info\"),\n            1 => std::env::set_var(\"RUST_LOG\", \"warpgate=debug\"),\n            2 => std::env::set_var(\"RUST_LOG\", \"warpgate=debug,russh=debug\"),\n            _ => std::env::set_var(\"RUST_LOG\", \"debug\"),\n        }\n    }\n\n    LogTracer::init().context(\"Failed to initialize log compatibility layer\")?;\n\n    let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);\n\n    let env_filter = Arc::new(EnvFilter::from_default_env());\n    let enable_colors = console::user_attended();\n\n    // Determine effective log format (CLI overrides config)\n    let log_format = cli\n        .log_format\n        .or(config.map(|c| c.store.log.format))\n        .unwrap_or_default();\n\n    let registry = tracing_subscriber::registry();\n\n    // #[cfg(all(debug_assertions, feature = \"tokio-console\"))]\n    // let console_layer = console_subscriber::spawn();\n    // #[cfg(all(debug_assertions, feature = \"tokio-console\"))]\n    // let registry = registry.with(console_layer);\n\n    let socket_layer = match config {\n        Some(config) => Some(make_socket_logger_layer(config).await),\n        None => None,\n    };\n\n    // Create JSON console layer (only active when format is JSON)\n    let json_layer = (log_format == LogFormat::Json).then(|| {\n        let env_filter = env_filter.clone();\n        make_json_console_logger_layer().with_filter(dynamic_filter_fn(move |m, c| {\n            env_filter.enabled(m, c.clone())\n        }))\n    });\n\n    // Create text console layers (only active when format is Text)\n    let text_layer_non_interactive = (log_format == LogFormat::Text && !console::user_attended())\n        .then({\n            let env_filter = env_filter.clone();\n            || {\n                tracing_subscriber::fmt::layer()\n                    .with_ansi(enable_colors)\n                    .with_timer(OffsetTime::new(\n                        offset,\n                        #[allow(clippy::unwrap_used)]\n                        format_description::parse(\"[day].[month].[year] [hour]:[minute]:[second]\")\n                            .unwrap(),\n                    ))\n                    .with_filter(dynamic_filter_fn(move |m, c| {\n                        env_filter.enabled(m, c.clone())\n                    }))\n            }\n        });\n\n    let text_layer_interactive =\n        (log_format == LogFormat::Text && console::user_attended()).then(|| {\n            tracing_subscriber::fmt::layer()\n                .compact()\n                .with_ansi(enable_colors)\n                .with_target(false)\n                .with_timer(OffsetTime::new(\n                    offset,\n                    #[allow(clippy::unwrap_used)]\n                    format_description::parse(\"[hour]:[minute]:[second]\").unwrap(),\n                ))\n                .with_filter(dynamic_filter_fn(move |m, c| {\n                    env_filter.enabled(m, c.clone())\n                }))\n        });\n\n    let registry = registry\n        .with(json_layer)\n        .with(text_layer_non_interactive)\n        .with(text_layer_interactive);\n\n    let registry = registry\n        .with(make_database_logger_layer())\n        .with(socket_layer);\n\n    registry.init();\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate/src/main.rs",
    "content": "mod commands;\nmod config;\nmod logging;\n\nuse std::path::PathBuf;\n\nuse anyhow::Result;\nuse clap::{ArgAction, Parser};\nuse logging::init_logging;\nuse tracing::*;\nuse warpgate_common::version::warpgate_version;\nuse warpgate_common::{GlobalParams, LogFormat, Secret};\n\nuse crate::config::load_config;\n\n#[derive(clap::Parser)]\n#[clap(author, about, long_about = None)]\npub struct Cli {\n    #[clap(subcommand)]\n    command: Commands,\n\n    #[clap(long, short, default_value = \"/etc/warpgate.yaml\", action=ArgAction::Set, env=\"WARPGATE_CONFIG\")]\n    config: PathBuf,\n\n    #[clap(long, short, action=ArgAction::Count)]\n    debug: u8,\n\n    /// Log output format (text or json)\n    #[clap(long, value_enum)]\n    log_format: Option<LogFormat>,\n\n    /// Do not tighten UNIX modes of config and data files\n    #[clap(long)]\n    skip_securing_files: bool,\n}\n\nimpl Cli {\n    #[allow(clippy::wrong_self_convention)]\n    pub fn into_global_params(&self) -> anyhow::Result<GlobalParams> {\n        warpgate_common::GlobalParams::new(self.config.clone(), !self.skip_securing_files)\n    }\n}\n\n#[derive(clap::Subcommand)]\npub(crate) enum Commands {\n    /// Run first-time setup and generate a config file\n    Setup {\n        /// Database URL\n        #[clap(long)]\n        database_url: Option<String>,\n    },\n    /// Run first-time setup non-interactively\n    UnattendedSetup {\n        /// Database URL\n        #[clap(long)]\n        database_url: Option<String>,\n\n        /// Directory to store data in\n        #[clap(long)]\n        data_path: String,\n\n        /// HTTP port\n        #[clap(long)]\n        http_port: u16,\n\n        /// Enable SSH and set port\n        #[clap(long)]\n        ssh_port: Option<u16>,\n\n        /// Enable MySQL and set port\n        #[clap(long)]\n        mysql_port: Option<u16>,\n\n        /// Enable PostgreSQL and set port\n        #[clap(long)]\n        postgres_port: Option<u16>,\n\n        /// Enable Kubernetes and set port\n        #[clap(long)]\n        kubernetes_port: Option<u16>,\n\n        /// Enable session recording\n        #[clap(long)]\n        record_sessions: bool,\n\n        /// Password for the initial user (required if WARPGATE_ADMIN_PASSWORD env var is not set)\n        #[clap(long)]\n        admin_password: Option<String>,\n\n        /// External host used to construct URLs (without a port or scheme)\n        #[clap(long)]\n        external_host: Option<String>,\n    },\n    /// Show Warpgate's SSH client keys\n    ClientKeys,\n    /// Run Warpgate\n    Run {\n        /// Enable an API token (passed via the `WARPGATE_ADMIN_TOKEN` env var) that automatically maps to the first admin user\n        #[clap(long, action=ArgAction::SetTrue)]\n        enable_admin_token: bool,\n    },\n    /// Perform basic config checks\n    Check,\n    /// Create a new user\n    CreateUser {\n        #[clap(action=ArgAction::Set)]\n        username: String,\n        /// Password (required if WARPGATE_NEW_USER_PASSWORD env var is not set)\n        #[clap(short, long, action=ArgAction::Set)]\n        password: Option<String>,\n        #[clap(short, long, action=ArgAction::Set)]\n        role: Option<String>,\n    },\n    /// Reset password and auth policy for a user\n    RecoverAccess {\n        #[clap(action=ArgAction::Set)]\n        username: Option<String>,\n    },\n    /// Show version information\n    Version,\n    /// Automatic healthcheck for running Warpgate in a container\n    Healthcheck,\n}\n\nasync fn _main() -> Result<()> {\n    let cli = Cli::parse();\n    let params = cli.into_global_params()?;\n\n    init_logging(load_config(&params, false).ok().as_ref(), &cli).await?;\n\n    #[allow(clippy::unwrap_used)]\n    rustls::crypto::aws_lc_rs::default_provider()\n        .install_default()\n        .unwrap();\n\n    match &cli.command {\n        Commands::Version => {\n            println!(\"warpgate {}\", warpgate_version());\n            Ok(())\n        }\n        Commands::Run { enable_admin_token } => {\n            crate::commands::run::command(&params, *enable_admin_token).await\n        }\n        Commands::Check => crate::commands::check::command(&params).await,\n        Commands::CreateUser {\n            username,\n            password: explicit_password,\n            role,\n        } => {\n            #[allow(clippy::collapsible_else_if)]\n            let password = if let Some(p) = explicit_password {\n                p.to_owned()\n            } else {\n                if let Ok(p) = std::env::var(\"WARPGATE_NEW_USER_PASSWORD\") {\n                    p\n                } else {\n                    error!(\"You must supply the password either through the --password option\");\n                    error!(\"or the WARPGATE_NEW_USER_PASSWORD environment variable.\");\n                    std::process::exit(1);\n                }\n            };\n\n            crate::commands::create_user::command(\n                &params,\n                username,\n                &Secret::new(password.clone()),\n                role,\n            )\n            .await\n        }\n        Commands::Setup { .. } | Commands::UnattendedSetup { .. } => {\n            crate::commands::setup::command(&cli, &params).await\n        }\n        Commands::ClientKeys => crate::commands::client_keys::command(&params).await,\n        Commands::RecoverAccess { username } => {\n            crate::commands::recover_access::command(&params, username).await\n        }\n        Commands::Healthcheck => crate::commands::healthcheck::command(&params).await,\n    }\n}\n\n#[tokio::main]\nasync fn main() {\n    if let Err(error) = _main().await {\n        error!(?error, \"Fatal error\");\n        std::process::exit(1);\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-admin\"\nversion = \"0.22.0\"\n\n[dependencies]\nanyhow.workspace = true\nasync-trait = { version = \"0.1\", default-features = false }\nbytes.workspace = true\nchrono = { version = \"0.4\", default-features = false }\nfutures.workspace = true\nhex.workspace = true\nmime_guess = { version = \"2.0\", default-features = false }\npoem.workspace = true\npoem-openapi.workspace = true\nrcgen.workspace = true\nrustls-pki-types.workspace = true\nrussh.workspace = true\nrust-embed = { version = \"8.3\", default-features = false }\nsea-orm.workspace = true\nserde.workspace = true\nserde_json.workspace = true\nthiserror.workspace = true\ntokio.workspace = true\ntracing.workspace = true\nuuid.workspace = true\nwarpgate-ca = { version = \"*\", path = \"../warpgate-ca\" }\nwarpgate-common = { version = \"*\", path = \"../warpgate-common\" }\nwarpgate-common-http = { path = \"../warpgate-common-http\" }\nwarpgate-tls = { version = \"*\", path = \"../warpgate-tls\" }\nwarpgate-core = { version = \"*\", path = \"../warpgate-core\" }\nwarpgate-db-entities = { version = \"*\", path = \"../warpgate-db-entities\" }\nwarpgate-ldap = { version = \"*\", path = \"../warpgate-ldap\" }\nwarpgate-protocol-ssh = { version = \"*\", path = \"../warpgate-protocol-ssh\" }\nwarpgate-protocol-kubernetes = { version = \"*\", path = \"../warpgate-protocol-kubernetes\" }\nregex.workspace = true\n"
  },
  {
    "path": "warpgate-admin/src/api/admin_roles.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::{Path, Query};\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{\n    ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set,\n};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, AdminRole as AdminRoleConfig, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_core::consts::BUILTIN_ADMIN_ROLE_NAME;\nuse warpgate_db_entities::{AdminRole, User};\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\n#[derive(Object)]\nstruct AdminRoleDataRequest {\n    name: String,\n    description: Option<String>,\n\n    targets_create: bool,\n    targets_edit: bool,\n    targets_delete: bool,\n\n    users_create: bool,\n    users_edit: bool,\n    users_delete: bool,\n\n    access_roles_create: bool,\n    access_roles_edit: bool,\n    access_roles_delete: bool,\n    access_roles_assign: bool,\n\n    sessions_view: bool,\n    sessions_terminate: bool,\n\n    recordings_view: bool,\n\n    tickets_create: bool,\n    tickets_delete: bool,\n\n    config_edit: bool,\n\n    admin_roles_manage: bool,\n}\n\n#[derive(ApiResponse)]\nenum GetAdminRolesResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<AdminRoleConfig>>),\n}\n\n#[derive(ApiResponse)]\nenum CreateAdminRoleResponse {\n    #[oai(status = 201)]\n    Created(Json<AdminRoleConfig>),\n}\n\n#[derive(ApiResponse)]\nenum GetAdminRoleResponse {\n    #[oai(status = 200)]\n    Ok(Json<AdminRoleConfig>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum UpdateAdminRoleResponse {\n    #[oai(status = 200)]\n    Ok(Json<AdminRoleConfig>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum DeleteAdminRoleResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 403)]\n    Forbidden,\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum GetAdminRoleUsersResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<warpgate_common::User>>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(\n        path = \"/admin-roles\",\n        method = \"get\",\n        operation_id = \"get_admin_roles\"\n    )]\n    async fn api_get_all_admin_roles(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        search: Query<Option<String>>,\n        _sec: AnySecurityScheme,\n    ) -> Result<GetAdminRolesResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n        let mut roles = AdminRole::Entity::find().order_by_asc(AdminRole::Column::Name);\n\n        if let Some(ref search) = *search {\n            let search = format!(\"%{search}%\");\n            roles = roles.filter(AdminRole::Column::Name.like(search));\n        }\n\n        let roles = roles.all(&*db).await?;\n        Ok(GetAdminRolesResponse::Ok(Json(\n            roles.into_iter().map(Into::into).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/admin-roles\",\n        method = \"post\",\n        operation_id = \"create_admin_role\"\n    )]\n    async fn api_create_admin_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<AdminRoleDataRequest>,\n        _sec: AnySecurityScheme,\n    ) -> Result<CreateAdminRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?;\n\n        let db = ctx.services.db.lock().await;\n        let values = AdminRole::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            name: Set(body.name.clone()),\n            description: Set(body.description.clone().unwrap_or_default()),\n            targets_create: Set(body.targets_create),\n            targets_edit: Set(body.targets_edit),\n            targets_delete: Set(body.targets_delete),\n            users_create: Set(body.users_create),\n            users_edit: Set(body.users_edit),\n            users_delete: Set(body.users_delete),\n            access_roles_create: Set(body.access_roles_create),\n            access_roles_edit: Set(body.access_roles_edit),\n            access_roles_delete: Set(body.access_roles_delete),\n            access_roles_assign: Set(body.access_roles_assign),\n            sessions_view: Set(body.sessions_view),\n            sessions_terminate: Set(body.sessions_terminate),\n            recordings_view: Set(body.recordings_view),\n            tickets_create: Set(body.tickets_create),\n            tickets_delete: Set(body.tickets_delete),\n            config_edit: Set(body.config_edit),\n            admin_roles_manage: Set(body.admin_roles_manage),\n        };\n\n        let role = values.insert(&*db).await?;\n        let role_config: AdminRoleConfig = role.into();\n        Ok(CreateAdminRoleResponse::Created(Json(role_config)))\n    }\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(\n        path = \"/admin-roles/:id\",\n        method = \"get\",\n        operation_id = \"get_admin_role\"\n    )]\n    async fn api_get_admin_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec: AnySecurityScheme,\n    ) -> Result<GetAdminRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n        let role = AdminRole::Entity::find_by_id(id.0).one(&*db).await?;\n        Ok(match role {\n            Some(r) => GetAdminRoleResponse::Ok(Json(r.into())),\n            None => GetAdminRoleResponse::NotFound,\n        })\n    }\n\n    #[oai(\n        path = \"/admin-roles/:id\",\n        method = \"put\",\n        operation_id = \"update_admin_role\"\n    )]\n    async fn api_update_admin_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<AdminRoleDataRequest>,\n        id: Path<Uuid>,\n        _sec: AnySecurityScheme,\n    ) -> Result<UpdateAdminRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?;\n\n        let db = ctx.services.db.lock().await;\n        let Some(role) = AdminRole::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(UpdateAdminRoleResponse::NotFound);\n        };\n\n        let mut model: AdminRole::ActiveModel = role.into();\n        model.name = Set(body.name.clone());\n        model.description = Set(body.description.clone().unwrap_or_default());\n        model.targets_create = Set(body.targets_create);\n        model.targets_edit = Set(body.targets_edit);\n        model.targets_delete = Set(body.targets_delete);\n        model.users_create = Set(body.users_create);\n        model.users_edit = Set(body.users_edit);\n        model.users_delete = Set(body.users_delete);\n        model.access_roles_create = Set(body.access_roles_create);\n        model.access_roles_edit = Set(body.access_roles_edit);\n        model.access_roles_delete = Set(body.access_roles_delete);\n        model.access_roles_assign = Set(body.access_roles_assign);\n        model.sessions_view = Set(body.sessions_view);\n        model.sessions_terminate = Set(body.sessions_terminate);\n        model.recordings_view = Set(body.recordings_view);\n        model.tickets_create = Set(body.tickets_create);\n        model.tickets_delete = Set(body.tickets_delete);\n        model.config_edit = Set(body.config_edit);\n        model.admin_roles_manage = Set(body.admin_roles_manage);\n        let role = model.update(&*db).await?;\n        Ok(UpdateAdminRoleResponse::Ok(Json(role.into())))\n    }\n\n    #[oai(\n        path = \"/admin-roles/:id\",\n        method = \"delete\",\n        operation_id = \"delete_admin_role\"\n    )]\n    async fn api_delete_admin_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec: AnySecurityScheme,\n    ) -> Result<DeleteAdminRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?;\n\n        let db = ctx.services.db.lock().await;\n        let Some(role) = AdminRole::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(DeleteAdminRoleResponse::NotFound);\n        };\n\n        // don't allow deleting builtin admin role\n        if role.name == BUILTIN_ADMIN_ROLE_NAME {\n            return Ok(DeleteAdminRoleResponse::Forbidden);\n        }\n\n        role.delete(&*db).await?;\n        Ok(DeleteAdminRoleResponse::Deleted)\n    }\n\n    #[oai(\n        path = \"/admin-roles/:id/users\",\n        method = \"get\",\n        operation_id = \"get_admin_role_users\"\n    )]\n    async fn api_get_admin_role_users(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec: AnySecurityScheme,\n    ) -> Result<GetAdminRoleUsersResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n        let Some((_, users)) = AdminRole::Entity::find_by_id(id.0)\n            .find_with_related(User::Entity)\n            .all(&*db)\n            .await\n            .map(|x| x.into_iter().next())\n            .map_err(WarpgateError::from)?\n        else {\n            return Ok(GetAdminRoleUsersResponse::NotFound);\n        };\n        let users: Vec<warpgate_common::User> = users\n            .into_iter()\n            .map(|x| x.try_into())\n            .collect::<Result<Vec<_>, _>>()?;\n        Ok(GetAdminRoleUsersResponse::Ok(Json(users)))\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/certificate_credentials.rs",
    "content": "use chrono::{DateTime, Utc};\nuse poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{\n    ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, QueryFilter, Set,\n};\nuse uuid::Uuid;\nuse warpgate_ca::{deserialize_certificate, serialize_certificate_serial};\nuse warpgate_common::{AdminPermission, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::{CertificateCredential, CertificateRevocation, Parameters, User};\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\nfn certificate_fingerprint(certificate_pem: &str) -> Result<String, WarpgateError> {\n    Ok(warpgate_ca::certificate_sha256_hex_fingerprint(\n        &warpgate_ca::deserialize_certificate(certificate_pem)?,\n    )?)\n}\n\n#[derive(Object)]\nstruct ExistingCertificateCredential {\n    id: Uuid,\n    label: String,\n    date_added: Option<DateTime<Utc>>,\n    last_used: Option<DateTime<Utc>>,\n    fingerprint: String,\n}\n\n#[derive(Object)]\nstruct IssuedCertificateCredential {\n    credential: ExistingCertificateCredential,\n    certificate_pem: String,\n}\n\n#[derive(Object)]\nstruct IssueCertificateCredentialRequest {\n    label: String,\n    public_key_pem: String,\n}\n\n#[derive(Object)]\nstruct UpdateCertificateCredential {\n    label: String,\n}\n\nimpl From<CertificateCredential::Model> for ExistingCertificateCredential {\n    fn from(credential: CertificateCredential::Model) -> Self {\n        Self {\n            id: credential.id,\n            date_added: credential.date_added,\n            last_used: credential.last_used,\n            label: credential.label,\n            fingerprint: certificate_fingerprint(&credential.certificate_pem)\n                .unwrap_or_else(|_| \"Invalid certificate\".into()),\n        }\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetCertificateCredentialsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<ExistingCertificateCredential>>),\n}\n\n#[derive(ApiResponse)]\nenum IssueCertificateCredentialResponse {\n    #[oai(status = 201)]\n    Issued(Json<IssuedCertificateCredential>),\n}\n\n#[derive(ApiResponse)]\nenum UpdateCertificateCredentialResponse {\n    #[oai(status = 200)]\n    Updated(Json<ExistingCertificateCredential>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/certificates\",\n        method = \"get\",\n        operation_id = \"get_certificate_credentials\"\n    )]\n    async fn api_get_all(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        _auth: AnySecurityScheme,\n    ) -> Result<GetCertificateCredentialsResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let objects = CertificateCredential::Entity::find()\n            .filter(CertificateCredential::Column::UserId.eq(*user_id))\n            .all(&*db)\n            .await?;\n\n        Ok(GetCertificateCredentialsResponse::Ok(Json(\n            objects.into_iter().map(Into::into).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/users/:user_id/credentials/certificates\",\n        method = \"post\",\n        operation_id = \"issue_certificate_credential\"\n    )]\n    async fn api_issue(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<IssueCertificateCredentialRequest>,\n        user_id: Path<Uuid>,\n        _auth: AnySecurityScheme,\n    ) -> Result<IssueCertificateCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n        let params = Parameters::Entity::get(&db).await?;\n        let ca =\n            warpgate_ca::deserialize_ca(&params.ca_certificate_pem, &params.ca_private_key_pem)?;\n        let user = User::Entity::find_by_id(*user_id)\n            .one(&*db)\n            .await?\n            .ok_or(WarpgateError::UserNotFound(user_id.to_string()))?;\n\n        let public_key_pem = body.public_key_pem.trim();\n        let client_cert =\n            warpgate_ca::issue_client_certificate(&ca, &user.username, public_key_pem, *user_id)?;\n\n        let client_cert_pem = warpgate_ca::certificate_to_pem(&client_cert)?;\n\n        let object = CertificateCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(*user_id),\n            date_added: Set(Some(Utc::now())),\n            last_used: Set(None),\n            label: Set(body.label.clone()),\n            certificate_pem: Set(client_cert_pem.clone()),\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        Ok(IssueCertificateCredentialResponse::Issued(Json(\n            IssuedCertificateCredential {\n                credential: object.into(),\n                certificate_pem: client_cert_pem,\n            },\n        )))\n    }\n}\n\n#[derive(ApiResponse)]\nenum RevokeCertificateCredentialResponse {\n    #[oai(status = 204)]\n    Revoked,\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/certificates/:id\",\n        method = \"patch\",\n        operation_id = \"update_certificate_credential\"\n    )]\n    async fn api_update(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<UpdateCertificateCredential>,\n        user_id: Path<Uuid>,\n        id: Path<Uuid>,\n        _auth: AnySecurityScheme,\n    ) -> Result<UpdateCertificateCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n        let Some(cred) = CertificateCredential::Entity::find_by_id(id.0)\n            .filter(CertificateCredential::Column::UserId.eq(*user_id))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(UpdateCertificateCredentialResponse::NotFound);\n        };\n\n        let mut am = cred.into_active_model();\n\n        am.label = Set(body.label.clone());\n        let model = am.update(&*db).await?;\n\n        Ok(UpdateCertificateCredentialResponse::Updated(Json(\n            model.into(),\n        )))\n    }\n\n    #[oai(\n        path = \"/users/:user_id/credentials/certificates/:id\",\n        method = \"delete\",\n        operation_id = \"revoke_certificate_credential\"\n    )]\n    async fn api_delete(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        id: Path<Uuid>,\n        _auth: AnySecurityScheme,\n    ) -> Result<RevokeCertificateCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(model) = CertificateCredential::Entity::find_by_id(id.0)\n            .filter(CertificateCredential::Column::UserId.eq(*user_id))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(RevokeCertificateCredentialResponse::NotFound);\n        };\n\n        let cert = deserialize_certificate(&model.certificate_pem)?;\n\n        CertificateRevocation::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            date_added: Set(Utc::now()),\n            serial_number_base64: Set(serialize_certificate_serial(&cert)),\n        }\n        .insert(&*db)\n        .await?;\n\n        model.delete(&*db).await?;\n        Ok(RevokeCertificateCredentialResponse::Revoked)\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/common.rs",
    "content": "use sea_orm::{\n    ColumnTrait, EntityTrait, JoinType, PaginatorTrait, QueryFilter, QuerySelect, RelationTrait,\n};\nuse warpgate_common::{AdminPermission, WarpgateError};\npub use warpgate_common_http::{RequestAuthorization, SessionAuthorization};\nuse warpgate_db_entities::{AdminRole, User, UserAdminRoleAssignment};\n\npub async fn has_admin_permission(\n    ctx: &warpgate_common_http::AuthenticatedRequestContext,\n    specific_permission: Option<AdminPermission>,\n) -> Result<bool, WarpgateError> {\n    // Admin tokens have all permissions\n    let auth = &ctx.auth;\n    if let RequestAuthorization::AdminToken = auth {\n        return Ok(true);\n    }\n\n    let username = match auth {\n        RequestAuthorization::Session(SessionAuthorization::User(ref username)) => username,\n        RequestAuthorization::Session(SessionAuthorization::Ticket { ref username, .. }) => {\n            username\n        }\n        RequestAuthorization::UserToken { ref username } => username,\n        RequestAuthorization::AdminToken => unreachable!(),\n    };\n\n    let db = ctx.services.db.lock().await;\n\n    let Some(user_model) = User::Entity::find()\n        .filter(User::Column::Username.eq(username))\n        .one(&*db)\n        .await?\n    else {\n        return Ok(false);\n    };\n\n    let mut query = UserAdminRoleAssignment::Entity::find()\n        .filter(UserAdminRoleAssignment::Column::UserId.eq(user_model.id))\n        .join(\n            JoinType::InnerJoin,\n            UserAdminRoleAssignment::Relation::AdminRole.def(),\n        );\n\n    if let Some(perm) = specific_permission {\n        query = query.filter(match perm {\n            AdminPermission::TargetsCreate => AdminRole::Column::TargetsCreate.eq(true),\n            AdminPermission::TargetsEdit => AdminRole::Column::TargetsEdit.eq(true),\n            AdminPermission::TargetsDelete => AdminRole::Column::TargetsDelete.eq(true),\n\n            AdminPermission::UsersCreate => AdminRole::Column::UsersCreate.eq(true),\n            AdminPermission::UsersEdit => AdminRole::Column::UsersEdit.eq(true),\n            AdminPermission::UsersDelete => AdminRole::Column::UsersDelete.eq(true),\n\n            AdminPermission::AccessRolesCreate => AdminRole::Column::AccessRolesCreate.eq(true),\n            AdminPermission::AccessRolesEdit => AdminRole::Column::AccessRolesEdit.eq(true),\n            AdminPermission::AccessRolesDelete => AdminRole::Column::AccessRolesDelete.eq(true),\n            AdminPermission::AccessRolesAssign => AdminRole::Column::AccessRolesAssign.eq(true),\n\n            AdminPermission::SessionsView => AdminRole::Column::SessionsView.eq(true),\n            AdminPermission::SessionsTerminate => AdminRole::Column::SessionsTerminate.eq(true),\n\n            AdminPermission::RecordingsView => AdminRole::Column::RecordingsView.eq(true),\n\n            AdminPermission::TicketsCreate => AdminRole::Column::TicketsCreate.eq(true),\n            AdminPermission::TicketsDelete => AdminRole::Column::TicketsDelete.eq(true),\n\n            AdminPermission::ConfigEdit => AdminRole::Column::ConfigEdit.eq(true),\n\n            AdminPermission::AdminRolesManage => AdminRole::Column::AdminRolesManage.eq(true),\n        });\n    }\n\n    let count = query.count(&*db).await?;\n    Ok(count > 0)\n}\n\npub async fn require_admin_permission(\n    ctx: &warpgate_common_http::AuthenticatedRequestContext,\n    specific_permission: Option<AdminPermission>,\n) -> Result<(), WarpgateError> {\n    if has_admin_permission(ctx, specific_permission).await? {\n        Ok(())\n    } else {\n        Err(match specific_permission {\n            Some(p) => WarpgateError::NoAdminPermission(p),\n            None => WarpgateError::NoAdminAccess,\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/known_hosts_detail.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::{ApiResponse, OpenApi};\nuse sea_orm::{EntityTrait, ModelTrait};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::KnownHost;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\npub struct Api;\n\n#[derive(ApiResponse)]\nenum DeleteSSHKnownHostResponse {\n    #[oai(status = 204)]\n    Deleted,\n\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/ssh/known-hosts/:id\",\n        method = \"delete\",\n        operation_id = \"delete_ssh_known_host\"\n    )]\n    async fn api_ssh_delete_known_host(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteSSHKnownHostResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let known_host = KnownHost::Entity::find_by_id(id.0).one(&*db).await?;\n\n        match known_host {\n            Some(known_host) => {\n                known_host.delete(&*db).await?;\n                Ok(DeleteSSHKnownHostResponse::Deleted)\n            }\n            None => Ok(DeleteSSHKnownHostResponse::NotFound),\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/known_hosts_list.rs",
    "content": "use std::str::FromStr;\n\nuse anyhow::Context;\nuse poem::web::Data;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse russh::keys::{Algorithm, PublicKey};\nuse sea_orm::{ActiveModelTrait, EntityTrait, Set};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::KnownHost;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[derive(ApiResponse)]\nenum GetSSHKnownHostsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<KnownHost::Model>>),\n}\n\n#[derive(ApiResponse)]\nenum AddSshKnownHostResponse {\n    #[oai(status = 200)]\n    Ok(Json<KnownHost::Model>),\n}\n\n#[derive(Object)]\nstruct AddSshKnownHostRequest {\n    host: String,\n    port: i32,\n    key_type: String,\n    key_base64: String,\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/ssh/known-hosts\",\n        method = \"post\",\n        operation_id = \"add_ssh_known_host\"\n    )]\n    async fn add_ssh_known_host(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<AddSshKnownHostRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<AddSshKnownHostResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        // Validate\n        Algorithm::from_str(&body.key_type).context(\"parsing key type\")?;\n        PublicKey::from_openssh(&format!(\"{} {}\", body.key_type, body.key_base64))\n            .context(\"parsing key\")?;\n\n        let db = ctx.services.db.lock().await;\n        let model = KnownHost::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            host: Set(body.host.clone()),\n            port: Set(body.port),\n            key_type: Set(body.key_type.clone()),\n            key_base64: Set(body.key_base64.clone()),\n        }\n        .insert(&*db)\n        .await?;\n        Ok(AddSshKnownHostResponse::Ok(Json(model)))\n    }\n\n    #[oai(\n        path = \"/ssh/known-hosts\",\n        method = \"get\",\n        operation_id = \"get_ssh_known_hosts\"\n    )]\n    async fn get_ssh_known_hosts(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetSSHKnownHostsResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n        let hosts = KnownHost::Entity::find().all(&*db).await?;\n        Ok(GetSSHKnownHostsResponse::Ok(Json(hosts)))\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/ldap_servers.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::{Path, Query};\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{\n    ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set,\n};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, Secret, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::LdapServer;\nuse warpgate_ldap::LdapUsernameAttribute;\nuse warpgate_tls::TlsMode;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\n#[derive(Object)]\nstruct ImportLdapUsersRequest {\n    dns: Vec<String>,\n}\n\n#[derive(ApiResponse)]\nenum ImportLdapUsersResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<String>>), // List of imported usernames\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct ImportApi;\n\n#[OpenApi]\nimpl ImportApi {\n    #[oai(\n        path = \"/ldap-servers/:id/import-users\",\n        method = \"post\",\n        operation_id = \"import_ldap_users\"\n    )]\n    async fn api_import_ldap_users(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        body: Json<ImportLdapUsersRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<ImportLdapUsersResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersCreate)).await?;\n\n        if !std::env::var(\"WARPGATE_UNDER_TEST\")\n            .unwrap_or_default()\n            .is_empty()\n        {\n            return Ok(ImportLdapUsersResponse::Ok(Json(vec![])));\n        }\n\n        let db = ctx.services.db.lock().await;\n        let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(ImportLdapUsersResponse::NotFound);\n        };\n        let ldap_config = warpgate_ldap::LdapConfig::try_from(&server)?;\n        let all_users = warpgate_ldap::list_users(&ldap_config).await?;\n        let mut imported = Vec::new();\n        for dn in &body.dns {\n            if let Some(user) = all_users.iter().find(|u| &u.dn == dn) {\n                let existing = warpgate_db_entities::User::Entity::find()\n                    .filter(warpgate_db_entities::User::Column::Username.eq(&user.username))\n                    .one(&*db)\n                    .await?;\n                if existing.is_none() {\n                    let values = warpgate_db_entities::User::ActiveModel {\n                        id: Set(Uuid::new_v4()),\n                        username: Set(user.username.clone()),\n                        credential_policy: Set(serde_json::to_value(\n                            warpgate_common::UserRequireCredentialsPolicy::default(),\n                        )?),\n                        description: Set(user.display_name.clone().unwrap_or_default()),\n                        rate_limit_bytes_per_second: Set(None),\n                        ldap_object_uuid: Set(Some(user.object_uuid)),\n                        ldap_server_id: Set(Some(server.id)),\n                    };\n                    values.insert(&*db).await?;\n                    imported.push(user.username.clone());\n                }\n            }\n        }\n        Ok(ImportLdapUsersResponse::Ok(Json(imported)))\n    }\n}\n\n#[derive(Object)]\nstruct LdapServerResponse {\n    id: Uuid,\n    name: String,\n    host: String,\n    port: i32,\n    bind_dn: String,\n    user_filter: String,\n    base_dns: Vec<String>,\n    tls_mode: TlsMode,\n    tls_verify: bool,\n    enabled: bool,\n    auto_link_sso_users: bool,\n    description: String,\n    username_attribute: LdapUsernameAttribute,\n    ssh_key_attribute: String,\n    uuid_attribute: String,\n}\n\nimpl From<LdapServer::Model> for LdapServerResponse {\n    fn from(model: LdapServer::Model) -> Self {\n        let base_dns: Vec<String> = serde_json::from_value(model.base_dns).unwrap_or_default();\n        Self {\n            id: model.id,\n            name: model.name,\n            host: model.host,\n            port: model.port,\n            bind_dn: model.bind_dn,\n            user_filter: model.user_filter,\n            base_dns,\n            tls_mode: TlsMode::from(model.tls_mode.as_str()),\n            tls_verify: model.tls_verify,\n            enabled: model.enabled,\n            auto_link_sso_users: model.auto_link_sso_users,\n            description: model.description,\n            username_attribute: model\n                .username_attribute\n                .as_str()\n                .try_into()\n                .unwrap_or(LdapUsernameAttribute::Cn),\n            ssh_key_attribute: model.ssh_key_attribute,\n            uuid_attribute: model.uuid_attribute,\n        }\n    }\n}\n\n#[derive(Object)]\nstruct CreateLdapServerRequest {\n    name: String,\n    host: String,\n    #[oai(default = \"default_port\")]\n    port: i32,\n    bind_dn: String,\n    bind_password: Secret<String>,\n    #[oai(default = \"default_user_filter\")]\n    user_filter: String,\n    #[oai(default = \"default_tls_mode\")]\n    tls_mode: TlsMode,\n    #[oai(default = \"default_tls_verify\")]\n    tls_verify: bool,\n    #[oai(default = \"default_enabled\")]\n    enabled: bool,\n    #[oai(default = \"default_auto_link_sso_users\")]\n    auto_link_sso_users: bool,\n    description: Option<String>,\n    #[oai(default = \"default_username_attribute\")]\n    username_attribute: LdapUsernameAttribute,\n    #[oai(default = \"default_ssh_key_attribute\")]\n    ssh_key_attribute: String,\n    #[oai(default = \"default_uuid_attribute\")]\n    uuid_attribute: String,\n}\n\nfn default_port() -> i32 {\n    389\n}\n\nfn default_user_filter() -> String {\n    \"(objectClass=person)\".to_string()\n}\n\nfn default_tls_mode() -> TlsMode {\n    TlsMode::Preferred\n}\n\nfn default_tls_verify() -> bool {\n    true\n}\n\nfn default_enabled() -> bool {\n    true\n}\n\nfn default_auto_link_sso_users() -> bool {\n    false\n}\n\nfn default_username_attribute() -> LdapUsernameAttribute {\n    LdapUsernameAttribute::Cn\n}\n\nfn default_ssh_key_attribute() -> String {\n    \"sshPublicKey\".to_string()\n}\n\nfn default_uuid_attribute() -> String {\n    String::new()\n}\n\n#[derive(Object)]\nstruct UpdateLdapServerRequest {\n    name: String,\n    host: String,\n    port: i32,\n    bind_dn: String,\n    bind_password: Option<Secret<String>>,\n    user_filter: String,\n    tls_mode: TlsMode,\n    tls_verify: bool,\n    enabled: bool,\n    auto_link_sso_users: bool,\n    description: Option<String>,\n    username_attribute: LdapUsernameAttribute,\n    ssh_key_attribute: String,\n    uuid_attribute: String,\n}\n\n#[derive(Object)]\nstruct TestLdapServerRequest {\n    host: String,\n    port: i32,\n    bind_dn: String,\n    bind_password: Secret<String>,\n    tls_mode: TlsMode,\n    tls_verify: bool,\n}\n\n#[derive(Object)]\nstruct TestLdapServerResponse {\n    success: bool,\n    message: String,\n    base_dns: Option<Vec<String>>,\n}\n\n#[derive(Object)]\nstruct LdapUserResponse {\n    username: String,\n    email: Option<String>,\n    display_name: Option<String>,\n    dn: String,\n}\n\nimpl From<warpgate_ldap::LdapUser> for LdapUserResponse {\n    fn from(user: warpgate_ldap::LdapUser) -> Self {\n        Self {\n            username: user.username,\n            email: user.email,\n            display_name: user.display_name,\n            dn: user.dn,\n        }\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetLdapServersResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<LdapServerResponse>>),\n}\n\n#[derive(ApiResponse)]\nenum CreateLdapServerResponse {\n    #[oai(status = 201)]\n    Created(Json<LdapServerResponse>),\n\n    #[oai(status = 409)]\n    Conflict(Json<String>),\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\n#[derive(ApiResponse)]\n#[allow(dead_code)]\nenum TestLdapServerConnectionResponse {\n    #[oai(status = 200)]\n    Ok(Json<TestLdapServerResponse>),\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(\n        path = \"/ldap-servers\",\n        method = \"get\",\n        operation_id = \"get_ldap_servers\"\n    )]\n    async fn api_get_all_ldap_servers(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        search: Query<Option<String>>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetLdapServersResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let mut query = LdapServer::Entity::find().order_by_asc(LdapServer::Column::Name);\n\n        if let Some(ref search) = *search {\n            let search_pattern = format!(\"%{search}%\");\n            query = query.filter(LdapServer::Column::Name.like(search_pattern));\n        }\n\n        let servers = query.all(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(GetLdapServersResponse::Ok(Json(\n            servers.into_iter().map(Into::into).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/ldap-servers\",\n        method = \"post\",\n        operation_id = \"create_ldap_server\"\n    )]\n    async fn api_create_ldap_server(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<CreateLdapServerRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreateLdapServerResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        if body.name.is_empty() {\n            return Ok(CreateLdapServerResponse::BadRequest(Json(\n                \"Name cannot be empty\".into(),\n            )));\n        }\n\n        let db = ctx.services.db.lock().await;\n\n        // Check if name already exists\n        let existing = LdapServer::Entity::find()\n            .filter(LdapServer::Column::Name.eq(&body.name))\n            .one(&*db)\n            .await?;\n\n        if existing.is_some() {\n            return Ok(CreateLdapServerResponse::Conflict(Json(\n                \"Name already exists\".into(),\n            )));\n        }\n\n        // Create LDAP config for discovery\n        let ldap_config = warpgate_ldap::LdapConfig {\n            host: body.host.clone(),\n            port: body.port as u16,\n            bind_dn: body.bind_dn.clone(),\n            bind_password: body.bind_password.expose_secret().clone(),\n            tls_mode: body.tls_mode,\n            tls_verify: body.tls_verify,\n            base_dns: vec![],\n            user_filter: body.user_filter.clone(),\n            username_attribute: body.username_attribute,\n            ssh_key_attribute: body.ssh_key_attribute.clone(),\n            uuid_attribute: if body.uuid_attribute.is_empty() {\n                None\n            } else {\n                Some(body.uuid_attribute.clone())\n            },\n        };\n\n        // Discover base DNs\n        let base_dns = if std::env::var(\"WARPGATE_UNDER_TEST\")\n            .unwrap_or_default()\n            .is_empty()\n        {\n            warpgate_ldap::discover_base_dns(&ldap_config).await?\n        } else {\n            vec![]\n        };\n\n        let base_dns_json = serde_json::to_value(&base_dns)?;\n\n        let values = LdapServer::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            name: Set(body.name.clone()),\n            host: Set(body.host.clone()),\n            port: Set(body.port),\n            bind_dn: Set(body.bind_dn.clone()),\n            bind_password: Set(body.bind_password.expose_secret().clone()),\n            user_filter: Set(body.user_filter.clone()),\n            base_dns: Set(base_dns_json),\n            tls_mode: Set(String::from(body.tls_mode)),\n            tls_verify: Set(body.tls_verify),\n            enabled: Set(body.enabled),\n            auto_link_sso_users: Set(body.auto_link_sso_users),\n            description: Set(body.description.clone().unwrap_or_default()),\n            username_attribute: Set(body.username_attribute.attribute_name().into()),\n            ssh_key_attribute: Set(body.ssh_key_attribute.clone()),\n            uuid_attribute: Set(body.uuid_attribute.clone()),\n        };\n\n        let server = values.insert(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(CreateLdapServerResponse::Created(Json(server.into())))\n    }\n\n    #[oai(\n        path = \"/ldap-servers/test\",\n        method = \"post\",\n        operation_id = \"test_ldap_server_connection\"\n    )]\n    async fn api_test_ldap_server(\n        &self,\n        body: Json<TestLdapServerRequest>,\n        ctx: Data<&AuthenticatedRequestContext>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<TestLdapServerConnectionResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        let ldap_config = warpgate_ldap::LdapConfig {\n            host: body.host.clone(),\n            port: body.port as u16,\n            bind_dn: body.bind_dn.clone(),\n            bind_password: body.bind_password.expose_secret().clone(),\n            tls_mode: body.tls_mode,\n            tls_verify: body.tls_verify,\n            base_dns: vec![],\n            user_filter: String::new(),\n            username_attribute: LdapUsernameAttribute::Cn,\n            ssh_key_attribute: \"sshPublicKey\".to_string(),\n            uuid_attribute: None,\n        };\n\n        if std::env::var(\"WARPGATE_UNDER_TEST\")\n            .unwrap_or_default()\n            .is_empty()\n        {\n            match warpgate_ldap::test_connection(&ldap_config).await {\n                Ok(_) => {\n                    // Try to discover base DNs\n                    let base_dns = warpgate_ldap::discover_base_dns(&ldap_config).await.ok();\n\n                    Ok(TestLdapServerConnectionResponse::Ok(Json(\n                        TestLdapServerResponse {\n                            success: true,\n                            message: \"Connection successful\".to_string(),\n                            base_dns,\n                        },\n                    )))\n                }\n                Err(e) => Ok(TestLdapServerConnectionResponse::Ok(Json(\n                    TestLdapServerResponse {\n                        success: false,\n                        message: format!(\"Connection failed: {}\", e),\n                        base_dns: None,\n                    },\n                ))),\n            }\n        } else {\n            Ok(TestLdapServerConnectionResponse::Ok(Json(\n                TestLdapServerResponse {\n                    success: true,\n                    message: \"Connection successful\".to_string(),\n                    base_dns: Some(vec![]),\n                },\n            )))\n        }\n    }\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(ApiResponse)]\nenum GetLdapServerResponse {\n    #[oai(status = 200)]\n    Ok(Json<LdapServerResponse>),\n\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\n#[allow(dead_code)]\nenum UpdateLdapServerResponse {\n    #[oai(status = 200)]\n    Ok(Json<LdapServerResponse>),\n\n    #[oai(status = 404)]\n    NotFound,\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\n#[derive(ApiResponse)]\nenum DeleteLdapServerResponse {\n    #[oai(status = 204)]\n    Deleted,\n\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(\n        path = \"/ldap-servers/:id\",\n        method = \"get\",\n        operation_id = \"get_ldap_server\"\n    )]\n    async fn api_get_ldap_server(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetLdapServerResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(GetLdapServerResponse::NotFound);\n        };\n\n        Ok(GetLdapServerResponse::Ok(Json(server.into())))\n    }\n\n    #[oai(\n        path = \"/ldap-servers/:id\",\n        method = \"put\",\n        operation_id = \"update_ldap_server\"\n    )]\n    async fn api_update_ldap_server(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        body: Json<UpdateLdapServerRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<UpdateLdapServerResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(UpdateLdapServerResponse::NotFound);\n        };\n\n        let mut model: LdapServer::ActiveModel = server.into();\n\n        // Update fields\n        model.name = Set(body.name.clone());\n        model.host = Set(body.host.clone());\n        model.port = Set(body.port);\n        model.bind_dn = Set(body.bind_dn.clone());\n        if let Some(password) = &body.bind_password {\n            model.bind_password = Set(password.expose_secret().clone());\n        }\n        model.user_filter = Set(body.user_filter.clone());\n        model.tls_mode = Set(String::from(body.tls_mode));\n        model.tls_verify = Set(body.tls_verify);\n        model.enabled = Set(body.enabled);\n        model.auto_link_sso_users = Set(body.auto_link_sso_users);\n        model.description = Set(body.description.clone().unwrap_or_default());\n        model.username_attribute = Set(body.username_attribute.attribute_name().into());\n        model.ssh_key_attribute = Set(body.ssh_key_attribute.clone());\n        model.uuid_attribute = Set(body.uuid_attribute.clone());\n\n        // Re-discover base DNs if connection details changed\n        let ldap_config = warpgate_ldap::LdapConfig {\n            host: body.host.clone(),\n            port: body.port as u16,\n            bind_dn: body.bind_dn.clone(),\n            bind_password: body\n                .bind_password\n                .as_ref()\n                .map(|p| p.expose_secret().clone())\n                .unwrap_or_else(|| model.bind_password.clone().unwrap()),\n            tls_mode: body.tls_mode,\n            tls_verify: body.tls_verify,\n            base_dns: vec![],\n            user_filter: body.user_filter.clone(),\n            username_attribute: body.username_attribute,\n            ssh_key_attribute: body.ssh_key_attribute.clone(),\n            uuid_attribute: None,\n        };\n\n        if let Ok(base_dns) = warpgate_ldap::discover_base_dns(&ldap_config).await {\n            model.base_dns = Set(serde_json::to_value(&base_dns)?);\n        }\n\n        let server = model.update(&*db).await?;\n\n        Ok(UpdateLdapServerResponse::Ok(Json(server.into())))\n    }\n\n    #[oai(\n        path = \"/ldap-servers/:id\",\n        method = \"delete\",\n        operation_id = \"delete_ldap_server\"\n    )]\n    async fn api_delete_ldap_server(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteLdapServerResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(DeleteLdapServerResponse::NotFound);\n        };\n\n        server.delete(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(DeleteLdapServerResponse::Deleted)\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetLdapUsersResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<LdapUserResponse>>),\n\n    #[oai(status = 404)]\n    NotFound,\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\npub struct QueryApi;\n\n#[OpenApi]\nimpl QueryApi {\n    #[oai(\n        path = \"/ldap-servers/:id/users\",\n        method = \"get\",\n        operation_id = \"get_ldap_users\"\n    )]\n    async fn api_get_ldap_users(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetLdapUsersResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersCreate)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(server) = LdapServer::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(GetLdapUsersResponse::NotFound);\n        };\n\n        if !std::env::var(\"WARPGATE_UNDER_TEST\")\n            .unwrap_or_default()\n            .is_empty()\n        {\n            return Ok(GetLdapUsersResponse::Ok(Json(vec![])));\n        }\n\n        let ldap_config = warpgate_ldap::LdapConfig::try_from(&server)?;\n        let users = match warpgate_ldap::list_users(&ldap_config).await {\n            Ok(users) => users,\n            Err(e) => {\n                return Ok(GetLdapUsersResponse::BadRequest(Json(format!(\n                    \"Failed to query users: {}\",\n                    e\n                ))))\n            }\n        };\n        let mut users = users.into_iter().map(Into::into).collect::<Vec<_>>();\n        users.sort_by_key(|u: &LdapUserResponse| u.username.clone());\n        Ok(GetLdapUsersResponse::Ok(Json(users)))\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/logs.rs",
    "content": "use chrono::{DateTime, Utc};\nuse poem::web::Data;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};\nuse uuid::Uuid;\nuse warpgate_common::WarpgateError;\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::LogEntry;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[derive(ApiResponse)]\nenum GetLogsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<LogEntry::Model>>),\n}\n\n#[derive(Object)]\nstruct GetLogsRequest {\n    before: Option<DateTime<Utc>>,\n    after: Option<DateTime<Utc>>,\n    limit: Option<u64>,\n    session_id: Option<Uuid>,\n    username: Option<String>,\n    search: Option<String>,\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(path = \"/logs\", method = \"post\", operation_id = \"get_logs\")]\n    async fn api_get_all_logs(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<GetLogsRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetLogsResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        use warpgate_db_entities::LogEntry;\n\n        let db = ctx.services.db.lock().await;\n        let mut q = LogEntry::Entity::find()\n            .order_by_desc(LogEntry::Column::Timestamp)\n            .limit(body.limit.unwrap_or(100));\n\n        if let Some(before) = body.before {\n            q = q.filter(LogEntry::Column::Timestamp.lt(before));\n        }\n        if let Some(after) = body.after {\n            q = q\n                .filter(LogEntry::Column::Timestamp.gt(after))\n                .order_by_asc(LogEntry::Column::Timestamp);\n        }\n        if let Some(ref session_id) = body.session_id {\n            q = q.filter(LogEntry::Column::SessionId.eq(*session_id));\n        }\n        if let Some(ref username) = body.username {\n            q = q.filter(LogEntry::Column::SessionId.eq(username.clone()));\n        }\n        if let Some(ref search) = body.search {\n            q = q.filter(\n                LogEntry::Column::Text\n                    .contains(search)\n                    .or(LogEntry::Column::Username.contains(search))\n                    .or(LogEntry::Column::Values.contains(search)),\n            );\n        }\n\n        let logs = q.all(&*db).await?;\n        Ok(GetLogsResponse::Ok(Json(logs)))\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/mod.rs",
    "content": "use poem_openapi::OpenApi;\n\nmod admin_roles;\nmod certificate_credentials;\nmod common;\nmod known_hosts_detail;\nmod known_hosts_list;\nmod ldap_servers;\nmod logs;\nmod otp_credentials;\nmod pagination;\nmod parameters;\nmod password_credentials;\nmod public_key_credentials;\npub mod recordings_detail;\nmod roles;\nmod sessions_detail;\npub mod sessions_list;\nmod ssh_connection_test;\nmod ssh_keys;\nmod sso_credentials;\nmod target_groups;\nmod targets;\nmod tickets_detail;\nmod tickets_list;\npub mod users;\n\npub use warpgate_common::api::AnySecurityScheme;\n\npub fn get() -> impl OpenApi {\n    // The arrangement of brackets here is simply due to\n    // the limited number of `impl OpenApi for (T1, T2, ...)` overloads\n    // and has no semantic meaning\n    (\n        (\n            (sessions_list::Api, sessions_detail::Api),\n            recordings_detail::Api,\n            (roles::ListApi, roles::DetailApi),\n            (admin_roles::ListApi, admin_roles::DetailApi),\n            (tickets_list::Api, tickets_detail::Api),\n            (known_hosts_list::Api, known_hosts_detail::Api),\n            ssh_keys::Api,\n            logs::Api,\n            (targets::ListApi, targets::DetailApi, targets::RolesApi),\n            (target_groups::ListApi, target_groups::DetailApi),\n            (users::ListApi, users::DetailApi, users::RolesApi),\n            (\n                password_credentials::ListApi,\n                password_credentials::DetailApi,\n            ),\n        ),\n        (\n            (sso_credentials::ListApi, sso_credentials::DetailApi),\n            (\n                public_key_credentials::ListApi,\n                public_key_credentials::DetailApi,\n            ),\n            (otp_credentials::ListApi, otp_credentials::DetailApi),\n            (\n                ldap_servers::ListApi,\n                ldap_servers::DetailApi,\n                ldap_servers::QueryApi,\n                ldap_servers::ImportApi,\n            ),\n            parameters::Api,\n            ssh_connection_test::Api,\n        ),\n        (\n            certificate_credentials::ListApi,\n            certificate_credentials::DetailApi,\n        ),\n    )\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/otp_credentials.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, Set};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, UserTotpCredential, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::OtpCredential;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\n#[derive(Object)]\nstruct ExistingOtpCredential {\n    id: Uuid,\n}\n\n#[derive(Object)]\nstruct NewOtpCredential {\n    secret_key: Vec<u8>,\n}\n\nimpl From<OtpCredential::Model> for ExistingOtpCredential {\n    fn from(credential: OtpCredential::Model) -> Self {\n        Self { id: credential.id }\n    }\n}\n\nimpl From<&NewOtpCredential> for UserTotpCredential {\n    fn from(credential: &NewOtpCredential) -> Self {\n        Self {\n            key: credential.secret_key.clone().into(),\n        }\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetOtpCredentialsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<ExistingOtpCredential>>),\n}\n\n#[derive(ApiResponse)]\nenum CreateOtpCredentialResponse {\n    #[oai(status = 201)]\n    Created(Json<ExistingOtpCredential>),\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/otp\",\n        method = \"get\",\n        operation_id = \"get_otp_credentials\"\n    )]\n    async fn api_get_all(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetOtpCredentialsResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let objects = OtpCredential::Entity::find()\n            .filter(OtpCredential::Column::UserId.eq(*user_id))\n            .all(&*db)\n            .await?;\n\n        Ok(GetOtpCredentialsResponse::Ok(Json(\n            objects.into_iter().map(Into::into).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/users/:user_id/credentials/otp\",\n        method = \"post\",\n        operation_id = \"create_otp_credential\"\n    )]\n    async fn api_create(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<NewOtpCredential>,\n        user_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreateOtpCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let object = OtpCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(*user_id),\n            ..OtpCredential::ActiveModel::from(UserTotpCredential::from(&*body))\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        Ok(CreateOtpCredentialResponse::Created(Json(object.into())))\n    }\n}\n\n#[derive(ApiResponse)]\nenum DeleteCredentialResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/otp/:id\",\n        method = \"delete\",\n        operation_id = \"delete_otp_credential\"\n    )]\n    async fn api_delete(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(role) = OtpCredential::Entity::find_by_id(id.0)\n            .filter(OtpCredential::Column::UserId.eq(*user_id))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(DeleteCredentialResponse::NotFound);\n        };\n\n        role.delete(&*db).await?;\n        Ok(DeleteCredentialResponse::Deleted)\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/pagination.rs",
    "content": "use poem_openapi::types::{ParseFromJSON, ToJSON};\nuse poem_openapi::Object;\nuse sea_orm::{ConnectionTrait, EntityTrait, FromQueryResult, PaginatorTrait, QuerySelect, Select};\nuse warpgate_common::WarpgateError;\n\n#[derive(Object)]\npub struct PaginatedResponse<T: ParseFromJSON + ToJSON + Send + Sync> {\n    items: Vec<T>,\n    offset: u64,\n    total: u64,\n}\n\npub struct PaginationParams {\n    pub offset: Option<u64>,\n    pub limit: Option<u64>,\n}\n\nimpl<T: ParseFromJSON + ToJSON + Send + Sync> PaginatedResponse<T> {\n    pub async fn new<E, M, C, P>(\n        query: Select<E>,\n        params: PaginationParams,\n        db: &'_ C,\n        postprocess: P,\n    ) -> Result<PaginatedResponse<T>, WarpgateError>\n    where\n        E: EntityTrait<Model = M>,\n        C: ConnectionTrait,\n        M: FromQueryResult + Sized + Send + Sync + 'static,\n        P: FnMut(E::Model) -> T,\n    {\n        let offset = params.offset.unwrap_or(0);\n        let limit = params.limit.unwrap_or(100);\n\n        let paginator = query.clone().paginate(db, limit);\n\n        let total = paginator.num_items().await?;\n\n        let query = query.offset(offset).limit(limit);\n\n        let items = query.all(db).await?;\n\n        let items = items.into_iter().map(postprocess).collect::<Vec<_>>();\n        Ok(PaginatedResponse {\n            items,\n            offset,\n            total,\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/parameters.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::ActiveValue::NotSet;\nuse sea_orm::{EntityTrait, IntoActiveModel, Set};\nuse serde::Serialize;\nuse warpgate_common::{AdminPermission, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::Parameters;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[derive(Serialize, Object)]\nstruct ParameterValues {\n    pub allow_own_credential_management: bool,\n    pub rate_limit_bytes_per_second: Option<u32>,\n    pub ssh_client_auth_publickey: bool,\n    pub ssh_client_auth_password: bool,\n    pub ssh_client_auth_keyboard_interactive: bool,\n    pub minimize_password_login: bool,\n}\n\n#[derive(Serialize, Object)]\nstruct ParameterUpdate {\n    pub allow_own_credential_management: bool,\n    pub rate_limit_bytes_per_second: Option<u32>,\n    pub ssh_client_auth_publickey: Option<bool>,\n    pub ssh_client_auth_password: Option<bool>,\n    pub ssh_client_auth_keyboard_interactive: Option<bool>,\n    pub minimize_password_login: Option<bool>,\n}\n\n#[derive(ApiResponse)]\nenum GetParametersResponse {\n    #[oai(status = 200)]\n    Ok(Json<ParameterValues>),\n}\n\n#[derive(ApiResponse)]\nenum UpdateParametersResponse {\n    #[oai(status = 201)]\n    Done,\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(path = \"/parameters\", method = \"get\", operation_id = \"get_parameters\")]\n    async fn api_get(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetParametersResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n        let parameters = Parameters::Entity::get(&db).await?;\n\n        Ok(GetParametersResponse::Ok(Json(ParameterValues {\n            allow_own_credential_management: parameters.allow_own_credential_management,\n            rate_limit_bytes_per_second: parameters.rate_limit_bytes_per_second.map(|x| x as u32),\n            ssh_client_auth_publickey: parameters.ssh_client_auth_publickey,\n            ssh_client_auth_password: parameters.ssh_client_auth_password,\n            ssh_client_auth_keyboard_interactive: parameters.ssh_client_auth_keyboard_interactive,\n            minimize_password_login: parameters.minimize_password_login,\n        })))\n    }\n\n    #[oai(\n        path = \"/parameters\",\n        method = \"put\",\n        operation_id = \"update_parameters\"\n    )]\n    async fn api_update_parameters(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<ParameterUpdate>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<UpdateParametersResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::ConfigEdit)).await?;\n\n        let services = &ctx.services;\n        let db = services.db.lock().await;\n        let mut parameters = Parameters::Entity::get(&db).await?.into_active_model();\n\n        parameters.allow_own_credential_management = Set(body.allow_own_credential_management);\n        parameters.rate_limit_bytes_per_second =\n            Set(body.rate_limit_bytes_per_second.map(|x| x as i64));\n        parameters.ssh_client_auth_publickey = body.ssh_client_auth_publickey.map_or(NotSet, Set);\n        parameters.ssh_client_auth_password = body.ssh_client_auth_password.map_or(NotSet, Set);\n        parameters.ssh_client_auth_keyboard_interactive = body\n            .ssh_client_auth_keyboard_interactive\n            .map_or(NotSet, Set);\n        parameters.minimize_password_login = body.minimize_password_login.map_or(NotSet, Set);\n\n        Parameters::Entity::update(parameters).exec(&*db).await?;\n        drop(db);\n\n        services\n            .rate_limiter_registry\n            .lock()\n            .await\n            .apply_new_rate_limits(&mut *services.state.lock().await)\n            .await?;\n\n        Ok(UpdateParametersResponse::Done)\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/password_credentials.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, Set};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, Secret, UserPasswordCredential, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::PasswordCredential;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\n#[derive(Object)]\nstruct ExistingPasswordCredential {\n    id: Uuid,\n}\n\n#[derive(Object)]\nstruct NewPasswordCredential {\n    password: Secret<String>,\n}\n\nimpl From<PasswordCredential::Model> for ExistingPasswordCredential {\n    fn from(credential: PasswordCredential::Model) -> Self {\n        Self { id: credential.id }\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetPasswordCredentialsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<ExistingPasswordCredential>>),\n}\n\n#[derive(ApiResponse)]\nenum CreatePasswordCredentialResponse {\n    #[oai(status = 201)]\n    Created(Json<ExistingPasswordCredential>),\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/passwords\",\n        method = \"get\",\n        operation_id = \"get_password_credentials\"\n    )]\n    async fn api_get_all(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetPasswordCredentialsResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let objects = PasswordCredential::Entity::find()\n            .filter(PasswordCredential::Column::UserId.eq(*user_id))\n            .all(&*db)\n            .await?;\n\n        Ok(GetPasswordCredentialsResponse::Ok(Json(\n            objects.into_iter().map(Into::into).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/users/:user_id/credentials/passwords\",\n        method = \"post\",\n        operation_id = \"create_password_credential\"\n    )]\n    async fn api_create(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<NewPasswordCredential>,\n        user_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreatePasswordCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let object = PasswordCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(*user_id),\n            ..PasswordCredential::ActiveModel::from(UserPasswordCredential::from_password(\n                &body.password,\n            ))\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        Ok(CreatePasswordCredentialResponse::Created(Json(\n            object.into(),\n        )))\n    }\n}\n\n#[derive(ApiResponse)]\nenum DeleteCredentialResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/passwords/:id\",\n        method = \"delete\",\n        operation_id = \"delete_password_credential\"\n    )]\n    async fn api_delete(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(model) = PasswordCredential::Entity::find_by_id(id.0)\n            .filter(PasswordCredential::Column::UserId.eq(*user_id))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(DeleteCredentialResponse::NotFound);\n        };\n\n        model.delete(&*db).await?;\n        Ok(DeleteCredentialResponse::Deleted)\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/public_key_credentials.rs",
    "content": "use chrono::{DateTime, Utc};\nuse poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{\n    ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, ModelTrait, QueryFilter,\n    Set,\n};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, UserPublicKeyCredential, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::PublicKeyCredential;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\nasync fn check_user_ldap_linked(\n    db: &DatabaseConnection,\n    user_id: Uuid,\n) -> Result<bool, WarpgateError> {\n    use warpgate_db_entities::User;\n\n    let user = User::Entity::find_by_id(user_id)\n        .one(db)\n        .await?\n        .ok_or_else(|| WarpgateError::UserNotFound(user_id.to_string()))?;\n\n    Ok(user.ldap_server_id.is_some())\n}\n\n/// Checks if a user is LDAP-linked and returns an error message if they are.\n/// Returns Ok(()) if the user is not LDAP-linked, or a formatted error string if they are.\nasync fn verify_user_not_ldap_linked(db: &DatabaseConnection, user_id: Uuid) -> Result<(), String> {\n    if check_user_ldap_linked(db, user_id).await.unwrap_or(false) {\n        Err(\"Cannot manage SSH keys for LDAP-linked users. Keys are synced from LDAP.\".to_string())\n    } else {\n        Ok(())\n    }\n}\n\n#[derive(Object)]\nstruct ExistingPublicKeyCredential {\n    id: Uuid,\n    label: String,\n    date_added: Option<DateTime<Utc>>,\n    last_used: Option<DateTime<Utc>>,\n    openssh_public_key: String,\n}\n\n#[derive(Object)]\nstruct NewPublicKeyCredential {\n    label: String,\n    openssh_public_key: String,\n}\n\nimpl From<PublicKeyCredential::Model> for ExistingPublicKeyCredential {\n    fn from(credential: PublicKeyCredential::Model) -> Self {\n        Self {\n            id: credential.id,\n            date_added: credential.date_added,\n            last_used: credential.last_used,\n            label: credential.label,\n            openssh_public_key: credential.openssh_public_key,\n        }\n    }\n}\n\nimpl TryFrom<&NewPublicKeyCredential> for UserPublicKeyCredential {\n    type Error = WarpgateError;\n\n    fn try_from(credential: &NewPublicKeyCredential) -> Result<Self, WarpgateError> {\n        let mut key = russh::keys::PublicKey::from_openssh(&credential.openssh_public_key)\n            .map_err(russh::keys::Error::from)?;\n\n        key.set_comment(\"\");\n\n        Ok(Self {\n            key: key.to_openssh().map_err(russh::keys::Error::from)?.into(),\n        })\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetPublicKeyCredentialsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<ExistingPublicKeyCredential>>),\n}\n\n#[derive(ApiResponse)]\nenum CreatePublicKeyCredentialResponse {\n    #[oai(status = 201)]\n    Created(Json<ExistingPublicKeyCredential>),\n    #[oai(status = 403)]\n    Forbidden(Json<String>),\n}\n\n#[derive(ApiResponse)]\nenum UpdatePublicKeyCredentialResponse {\n    #[oai(status = 200)]\n    Updated(Json<ExistingPublicKeyCredential>),\n    #[oai(status = 404)]\n    NotFound,\n    #[oai(status = 403)]\n    Forbidden(Json<String>),\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/public-keys\",\n        method = \"get\",\n        operation_id = \"get_public_key_credentials\"\n    )]\n    async fn api_get_all(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetPublicKeyCredentialsResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let objects = PublicKeyCredential::Entity::find()\n            .filter(PublicKeyCredential::Column::UserId.eq(*user_id))\n            .all(&*db)\n            .await?;\n\n        Ok(GetPublicKeyCredentialsResponse::Ok(Json(\n            objects.into_iter().map(Into::into).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/users/:user_id/credentials/public-keys\",\n        method = \"post\",\n        operation_id = \"create_public_key_credential\"\n    )]\n    async fn api_create(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<NewPublicKeyCredential>,\n        user_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreatePublicKeyCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        // Check if user is LDAP-linked\n        if let Err(msg) = verify_user_not_ldap_linked(&db, *user_id).await {\n            return Ok(CreatePublicKeyCredentialResponse::Forbidden(Json(msg)));\n        }\n\n        let object = PublicKeyCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(*user_id),\n            date_added: Set(Some(Utc::now())),\n            last_used: Set(None),\n            label: Set(body.label.clone()),\n            ..PublicKeyCredential::ActiveModel::from(UserPublicKeyCredential::try_from(&*body)?)\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        Ok(CreatePublicKeyCredentialResponse::Created(Json(\n            object.into(),\n        )))\n    }\n}\n\n#[derive(ApiResponse)]\nenum DeleteCredentialResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 404)]\n    NotFound,\n    #[oai(status = 403)]\n    Forbidden(Json<String>),\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/public-keys/:id\",\n        method = \"put\",\n        operation_id = \"update_public_key_credential\"\n    )]\n    async fn api_update(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<NewPublicKeyCredential>,\n        user_id: Path<Uuid>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<UpdatePublicKeyCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        // Check if user is LDAP-linked\n        if let Err(msg) = verify_user_not_ldap_linked(&db, *user_id).await {\n            return Ok(UpdatePublicKeyCredentialResponse::Forbidden(Json(msg)));\n        }\n\n        let model = PublicKeyCredential::ActiveModel {\n            id: Set(id.0),\n            user_id: Set(*user_id),\n            date_added: Set(Some(Utc::now())),\n            label: Set(body.label.clone()),\n            ..<_>::from(UserPublicKeyCredential::try_from(&*body)?)\n        }\n        .update(&*db)\n        .await;\n\n        match model {\n            Ok(model) => Ok(UpdatePublicKeyCredentialResponse::Updated(Json(\n                model.into(),\n            ))),\n            Err(DbErr::RecordNotFound(_)) => Ok(UpdatePublicKeyCredentialResponse::NotFound),\n            Err(e) => Err(e.into()),\n        }\n    }\n\n    #[oai(\n        path = \"/users/:user_id/credentials/public-keys/:id\",\n        method = \"delete\",\n        operation_id = \"delete_public_key_credential\"\n    )]\n    async fn api_delete(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        // Check if user is LDAP-linked\n        if let Err(msg) = verify_user_not_ldap_linked(&db, *user_id).await {\n            return Ok(DeleteCredentialResponse::Forbidden(Json(msg)));\n        }\n\n        let Some(model) = PublicKeyCredential::Entity::find_by_id(id.0)\n            .filter(PublicKeyCredential::Column::UserId.eq(*user_id))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(DeleteCredentialResponse::NotFound);\n        };\n\n        model.delete(&*db).await?;\n        Ok(DeleteCredentialResponse::Deleted)\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/recordings_detail.rs",
    "content": "use std::sync::Arc;\n\nuse anyhow::Context;\nuse bytes::Bytes;\nuse futures::{SinkExt, StreamExt};\nuse poem::error::{InternalServerError, NotFoundError};\nuse poem::web::websocket::{Message, WebSocket};\nuse poem::web::Data;\nuse poem::{handler, IntoResponse};\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, OpenApi};\nuse sea_orm::{ColumnTrait, EntityTrait, QueryFilter};\nuse serde_json::json;\nuse tokio::fs::File;\nuse tokio::io::{AsyncBufReadExt, BufReader};\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::AdminPermission;\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_core::recordings::{AsciiCast, SessionRecordings, TerminalRecordingItem};\nuse warpgate_db_entities::Recording::{self, RecordingKind};\nuse warpgate_protocol_kubernetes::recording::{\n    KubernetesRecordingItem, KubernetesRecordingItemApiObject,\n};\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[derive(ApiResponse)]\nenum GetRecordingResponse {\n    #[oai(status = 200)]\n    Ok(Json<Recording::Model>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum GetKubernetesRecordingResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<KubernetesRecordingItemApiObject>>),\n}\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/recordings/:id\",\n        method = \"get\",\n        operation_id = \"get_recording\"\n    )]\n    async fn api_get_recording(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> poem::Result<GetRecordingResponse> {\n        require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let recording = Recording::Entity::find_by_id(id.0)\n            .one(&*db)\n            .await\n            .map_err(InternalServerError)?;\n\n        match recording {\n            Some(recording) => Ok(GetRecordingResponse::Ok(Json(recording))),\n            None => Ok(GetRecordingResponse::NotFound),\n        }\n    }\n\n    #[oai(\n        path = \"/recordings/:id/kubernetes\",\n        method = \"get\",\n        operation_id = \"get_kubernetes_recording\"\n    )]\n    async fn api_get_recording_kubernetes(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> poem::Result<GetKubernetesRecordingResponse> {\n        require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?;\n\n        let db = ctx.services.db.lock().await;\n        let recordings = ctx.services.recordings.lock().await;\n\n        let recording = Recording::Entity::find_by_id(id.0)\n            .filter(Recording::Column::Kind.eq(RecordingKind::Kubernetes))\n            .one(&*db)\n            .await\n            .map_err(InternalServerError)?;\n\n        let Some(recording) = recording else {\n            return Err(NotFoundError.into());\n        };\n\n        let path = recordings.path_for(&recording.session_id, &recording.name);\n\n        let file = File::open(&path).await.map_err(InternalServerError)?;\n        let reader = BufReader::new(file);\n        let mut lines = reader.lines();\n        let mut content = Vec::new();\n\n        while let Some(line) = lines.next_line().await.context(\"reading recording\")? {\n            let item: KubernetesRecordingItem =\n                serde_json::from_str(&line).context(\"deserializing recording item\")?;\n            content.push(KubernetesRecordingItemApiObject::from(item));\n        }\n\n        Ok(GetKubernetesRecordingResponse::Ok(Json(content)))\n    }\n}\n\n#[handler]\npub async fn api_get_recording_cast(\n    ctx: Data<&AuthenticatedRequestContext>,\n    recordings: Data<&Arc<Mutex<SessionRecordings>>>,\n    id: poem::web::Path<Uuid>,\n) -> poem::Result<String> {\n    require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?;\n\n    let db = ctx.services.db.lock().await;\n\n    let recording = Recording::Entity::find_by_id(id.0)\n        .filter(Recording::Column::Kind.eq(RecordingKind::Terminal))\n        .one(&*db)\n        .await\n        .map_err(InternalServerError)?;\n\n    let Some(recording) = recording else {\n        return Err(NotFoundError.into());\n    };\n\n    let path = {\n        recordings\n            .lock()\n            .await\n            .path_for(&recording.session_id, &recording.name)\n    };\n\n    let mut response = vec![]; //String::new();\n\n    let mut last_size = (0, 0);\n    let file = File::open(&path).await.map_err(InternalServerError)?;\n    let reader = BufReader::new(file);\n    let mut lines = reader.lines();\n    while let Some(line) = lines.next_line().await.map_err(InternalServerError)? {\n        let entry: TerminalRecordingItem =\n            serde_json::from_str(&line[..]).map_err(InternalServerError)?;\n        let asciicast: AsciiCast = entry.into();\n        response.push(serde_json::to_string(&asciicast).map_err(InternalServerError)?);\n        if let AsciiCast::Header { width, height, .. } = asciicast {\n            last_size = (width, height);\n        }\n    }\n\n    response.insert(\n        0,\n        serde_json::to_string(&AsciiCast::Header {\n            time: 0.0,\n            version: 2,\n            width: last_size.0,\n            height: last_size.1,\n            title: recording.name,\n        })\n        .map_err(InternalServerError)?,\n    );\n\n    Ok(response.join(\"\\n\"))\n}\n\n#[handler]\npub async fn api_get_recording_tcpdump(\n    ctx: Data<&AuthenticatedRequestContext>,\n    recordings: Data<&Arc<Mutex<SessionRecordings>>>,\n    id: poem::web::Path<Uuid>,\n) -> poem::Result<Bytes> {\n    require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?;\n\n    let db = ctx.services.db.lock().await;\n\n    let recording = Recording::Entity::find_by_id(id.0)\n        .filter(Recording::Column::Kind.eq(RecordingKind::Traffic))\n        .one(&*db)\n        .await\n        .map_err(InternalServerError)?;\n\n    let Some(recording) = recording else {\n        return Err(NotFoundError.into());\n    };\n\n    let path = {\n        recordings\n            .lock()\n            .await\n            .path_for(&recording.session_id, &recording.name)\n    };\n\n    let content = std::fs::read(path).map_err(InternalServerError)?;\n\n    Ok(Bytes::from(content))\n}\n\n#[handler]\npub async fn api_get_recording_stream(\n    ws: WebSocket,\n    recordings: Data<&Arc<Mutex<SessionRecordings>>>,\n    id: poem::web::Path<Uuid>,\n) -> impl IntoResponse {\n    let recordings = recordings.lock().await;\n    let receiver = recordings.subscribe_live(&id).await;\n\n    ws.on_upgrade(|socket| async move {\n        let (mut sink, _) = socket.split();\n\n        sink.send(Message::Text(serde_json::to_string(&json!({\n            \"start\": true,\n            \"live\": receiver.is_some(),\n        }))?))\n        .await?;\n\n        if let Some(mut receiver) = receiver {\n            tokio::spawn(async move {\n                if let Err(error) = async {\n                    while let Ok(data) = receiver.recv().await {\n                        let content: TerminalRecordingItem = serde_json::from_slice(&data)?;\n                        let cast: AsciiCast = content.into();\n                        let msg = serde_json::to_string(&json!({ \"data\": cast }))?;\n                        sink.send(Message::Text(msg)).await?;\n                    }\n                    sink.send(Message::Text(serde_json::to_string(&json!({\n                        \"end\": true,\n                    }))?))\n                    .await?;\n                    Ok::<(), anyhow::Error>(())\n                }\n                .await\n                {\n                    error!(%error, \"Livestream error:\");\n                }\n            });\n        }\n\n        Ok::<(), anyhow::Error>(())\n    })\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/roles.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::{Path, Query};\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{\n    ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set,\n};\nuse uuid::Uuid;\nuse warpgate_common::{\n    AdminPermission, Role as RoleConfig, Target as TargetConfig, User as UserConfig, WarpgateError,\n};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_core::consts::BUILTIN_ADMIN_ROLE_NAME;\nuse warpgate_db_entities::{Role, Target, User};\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\n#[derive(Object)]\nstruct RoleDataRequest {\n    name: String,\n    description: Option<String>,\n}\n\n#[derive(ApiResponse)]\nenum GetRolesResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<RoleConfig>>),\n}\n#[derive(ApiResponse)]\nenum CreateRoleResponse {\n    #[oai(status = 201)]\n    Created(Json<RoleConfig>),\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(path = \"/roles\", method = \"get\", operation_id = \"get_roles\")]\n    async fn api_get_all_roles(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        search: Query<Option<String>>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetRolesResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        // listing roles is allowed for any administrator\n        let db = ctx.services.db.lock().await;\n\n        let mut roles = Role::Entity::find().order_by_asc(Role::Column::Name);\n\n        if let Some(ref search) = *search {\n            let search = format!(\"%{search}%\");\n            roles = roles.filter(Role::Column::Name.like(search));\n        }\n\n        let roles = roles.all(&*db).await?;\n\n        Ok(GetRolesResponse::Ok(Json(\n            roles.into_iter().map(Into::into).collect(),\n        )))\n    }\n\n    #[oai(path = \"/roles\", method = \"post\", operation_id = \"create_role\")]\n    async fn api_create_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<RoleDataRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreateRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AccessRolesCreate)).await?;\n\n        use warpgate_db_entities::Role;\n\n        if body.name.is_empty() {\n            return Ok(CreateRoleResponse::BadRequest(Json(\"name\".into())));\n        }\n\n        let db = ctx.services.db.lock().await;\n\n        let values = Role::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            name: Set(body.name.clone()),\n            description: Set(body.description.clone().unwrap_or_default()),\n        };\n\n        let role = values.insert(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(CreateRoleResponse::Created(Json(role.into())))\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetRoleResponse {\n    #[oai(status = 200)]\n    Ok(Json<RoleConfig>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum UpdateRoleResponse {\n    #[oai(status = 200)]\n    Ok(Json<RoleConfig>),\n    #[oai(status = 403)]\n    Forbidden,\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum DeleteRoleResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 403)]\n    Forbidden,\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum GetRoleTargetsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<TargetConfig>>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum GetRoleUsersResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<UserConfig>>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(path = \"/role/:id\", method = \"get\", operation_id = \"get_role\")]\n    async fn api_get_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let role = Role::Entity::find_by_id(id.0).one(&*db).await?;\n\n        Ok(match role {\n            Some(role) => GetRoleResponse::Ok(Json(role.into())),\n            None => GetRoleResponse::NotFound,\n        })\n    }\n\n    #[oai(path = \"/role/:id\", method = \"put\", operation_id = \"update_role\")]\n    async fn api_update_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<RoleDataRequest>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<UpdateRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AccessRolesEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(UpdateRoleResponse::NotFound);\n        };\n\n        if role.name == BUILTIN_ADMIN_ROLE_NAME {\n            return Ok(UpdateRoleResponse::Forbidden);\n        }\n\n        let mut model: Role::ActiveModel = role.into();\n        model.name = Set(body.name.clone());\n        model.description = Set(body.description.clone().unwrap_or_default());\n        let role = model.update(&*db).await?;\n\n        Ok(UpdateRoleResponse::Ok(Json(role.into())))\n    }\n\n    #[oai(path = \"/role/:id\", method = \"delete\", operation_id = \"delete_role\")]\n    async fn api_delete_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AccessRolesDelete)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(DeleteRoleResponse::NotFound);\n        };\n\n        if role.name == BUILTIN_ADMIN_ROLE_NAME {\n            return Ok(DeleteRoleResponse::Forbidden);\n        }\n\n        role.delete(&*db).await?;\n        Ok(DeleteRoleResponse::Deleted)\n    }\n\n    #[oai(\n        path = \"/role/:id/targets\",\n        method = \"get\",\n        operation_id = \"get_role_targets\"\n    )]\n    async fn api_get_role_targets(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetRoleTargetsResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(GetRoleTargetsResponse::NotFound);\n        };\n\n        let targets = role.find_related(Target::Entity).all(&*db).await?;\n\n        Ok(GetRoleTargetsResponse::Ok(Json(\n            targets\n                .into_iter()\n                .map(TryInto::try_into)\n                .collect::<Result<Vec<_>, serde_json::Error>>()?,\n        )))\n    }\n\n    #[oai(\n        path = \"/role/:id/users\",\n        method = \"get\",\n        operation_id = \"get_role_users\"\n    )]\n    async fn api_get_role_users(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetRoleUsersResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(role) = Role::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(GetRoleUsersResponse::NotFound);\n        };\n\n        let users = role.find_related(User::Entity).all(&*db).await?;\n\n        Ok(GetRoleUsersResponse::Ok(Json(\n            users\n                .into_iter()\n                .map(TryInto::try_into)\n                .collect::<Result<Vec<_>, WarpgateError>>()?,\n        )))\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/sessions_detail.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, OpenApi};\nuse sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_core::SessionSnapshot;\nuse warpgate_db_entities::{Recording, Session};\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[allow(clippy::large_enum_variant)]\n#[derive(ApiResponse)]\nenum GetSessionResponse {\n    #[oai(status = 200)]\n    Ok(Json<SessionSnapshot>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum GetSessionRecordingsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<Recording::Model>>),\n}\n\n#[derive(ApiResponse)]\nenum CloseSessionResponse {\n    #[oai(status = 201)]\n    Ok,\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(path = \"/sessions/:id\", method = \"get\", operation_id = \"get_session\")]\n    async fn api_get_session(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetSessionResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::SessionsView)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let session = Session::Entity::find_by_id(id.0).one(&*db).await?;\n\n        match session {\n            Some(session) => Ok(GetSessionResponse::Ok(Json(session.into()))),\n            None => Ok(GetSessionResponse::NotFound),\n        }\n    }\n\n    #[oai(\n        path = \"/sessions/:id/recordings\",\n        method = \"get\",\n        operation_id = \"get_session_recordings\"\n    )]\n    async fn api_get_session_recordings(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetSessionRecordingsResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::RecordingsView)).await?;\n\n        let db = ctx.services.db.lock().await;\n        let recordings: Vec<Recording::Model> = Recording::Entity::find()\n            .order_by_desc(Recording::Column::Started)\n            .filter(Recording::Column::SessionId.eq(id.0))\n            .all(&*db)\n            .await?;\n        Ok(GetSessionRecordingsResponse::Ok(Json(recordings)))\n    }\n\n    #[oai(\n        path = \"/sessions/:id/close\",\n        method = \"post\",\n        operation_id = \"close_session\"\n    )]\n    async fn api_close_session(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CloseSessionResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::SessionsTerminate)).await?;\n\n        let state = ctx.services.state.lock().await;\n\n        if let Some(s) = state.sessions.get(&id) {\n            let mut session = s.lock().await;\n            session.handle.close();\n            Ok(CloseSessionResponse::Ok)\n        } else {\n            Ok(CloseSessionResponse::NotFound)\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/sessions_list.rs",
    "content": "use futures::{SinkExt, StreamExt};\nuse poem::session::Session;\nuse poem::web::websocket::{Message, WebSocket};\nuse poem::web::Data;\nuse poem::{handler, IntoResponse};\nuse poem_openapi::param::Query;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, OpenApi};\nuse sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};\nuse warpgate_common::{AdminPermission, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_core::SessionSnapshot;\n\nuse super::pagination::{PaginatedResponse, PaginationParams};\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[derive(ApiResponse)]\nenum GetSessionsResponse {\n    #[oai(status = 200)]\n    Ok(Json<PaginatedResponse<SessionSnapshot>>),\n}\n\n#[derive(ApiResponse)]\nenum CloseAllSessionsResponse {\n    #[oai(status = 201)]\n    Ok,\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(path = \"/sessions\", method = \"get\", operation_id = \"get_sessions\")]\n    async fn api_get_all_sessions(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        offset: Query<Option<u64>>,\n        limit: Query<Option<u64>>,\n        active_only: Query<Option<bool>>,\n        logged_in_only: Query<Option<bool>>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> poem::Result<GetSessionsResponse> {\n        require_admin_permission(&ctx, Some(AdminPermission::SessionsView)).await?;\n\n        use warpgate_db_entities::Session;\n\n        let db = ctx.services.db.lock().await;\n        let mut q = Session::Entity::find().order_by_desc(Session::Column::Started);\n\n        if active_only.unwrap_or(false) {\n            q = q.filter(Session::Column::Ended.is_null());\n        }\n        if logged_in_only.unwrap_or(false) {\n            q = q.filter(Session::Column::Username.is_not_null());\n        }\n\n        Ok(GetSessionsResponse::Ok(Json(\n            PaginatedResponse::new(\n                q,\n                PaginationParams {\n                    limit: *limit,\n                    offset: *offset,\n                },\n                &*db,\n                Into::into,\n            )\n            .await?,\n        )))\n    }\n\n    #[oai(\n        path = \"/sessions\",\n        method = \"delete\",\n        operation_id = \"close_all_sessions\"\n    )]\n    async fn api_close_all_sessions(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        session: &Session,\n        _sec_scheme: AnySecurityScheme,\n    ) -> poem::Result<CloseAllSessionsResponse> {\n        require_admin_permission(&ctx, Some(AdminPermission::SessionsTerminate)).await?;\n\n        let state = ctx.services.state.lock().await;\n\n        for s in state.sessions.values() {\n            let mut session = s.lock().await;\n            session.handle.close();\n        }\n\n        session.purge();\n\n        Ok(CloseAllSessionsResponse::Ok)\n    }\n}\n\n#[handler]\npub async fn api_get_sessions_changes_stream(\n    ctx: Data<&AuthenticatedRequestContext>,\n    ws: WebSocket,\n) -> Result<impl IntoResponse, WarpgateError> {\n    require_admin_permission(&ctx, Some(AdminPermission::SessionsView)).await?;\n\n    let mut receiver = ctx.services.state.lock().await.subscribe();\n\n    Ok(ws\n        .on_upgrade(|socket| async move {\n            let (mut sink, _) = socket.split();\n\n            while receiver.recv().await.is_ok() {\n                sink.send(Message::Text(\"\".to_string())).await?;\n            }\n\n            Ok::<(), anyhow::Error>(())\n        })\n        .into_response())\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/ssh_connection_test.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::payload::{Json, PlainText};\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse russh::keys::PublicKeyBase64;\nuse uuid::Uuid;\nuse warpgate_common::{\n    AdminPermission, SSHTargetAuth, SshTargetPasswordAuth, TargetSSHOptions, WarpgateError,\n};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_protocol_ssh::{RCCommand, RCEvent, RemoteClient};\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[derive(Object)]\nstruct CheckSshHostKeyRequest {\n    host: String,\n    port: u16,\n}\n\n#[derive(Object)]\nstruct CheckSshHostKeyResponseBody {\n    remote_key_type: String,\n    remote_key_base64: String,\n}\n\n#[derive(ApiResponse)]\nenum CheckSshHostKeyResponse {\n    #[oai(status = 200)]\n    Ok(Json<CheckSshHostKeyResponseBody>),\n    #[oai(status = 500)]\n    Error(PlainText<String>),\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/ssh/check-host-key\",\n        method = \"post\",\n        operation_id = \"check_ssh_host_key\"\n    )]\n    async fn api_ssh_check_host_key(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<CheckSshHostKeyRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CheckSshHostKeyResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::TargetsEdit)).await?;\n\n        let mut handles = RemoteClient::create(Uuid::new_v4(), ctx.services.clone())?;\n\n        let _ = handles.command_tx.send((\n            RCCommand::Connect(TargetSSHOptions {\n                host: body.host.clone(),\n                port: body.port,\n                username: \"\".into(),\n                allow_insecure_algos: None,\n                auth: SSHTargetAuth::Password(SshTargetPasswordAuth {\n                    password: \"\".to_string().into(),\n                }),\n            }),\n            None,\n        ));\n\n        let fut = async move {\n            let key = loop {\n                match handles.event_rx.recv().await {\n                    Some(RCEvent::HostKeyReceived(key)) => break key,\n                    Some(RCEvent::ConnectionError(err)) => return Err(anyhow::Error::from(err)),\n                    Some(RCEvent::Error(err)) => return Err(err),\n                    None => anyhow::bail!(\"Failed to connect to target\"),\n                    _ => (),\n                }\n            };\n            anyhow::Ok(key)\n        };\n\n        // Result is matched manually since we need to manually format\n        // the error message with :# to included the nested errors here\n        match fut.await {\n            Ok(key) => Ok(CheckSshHostKeyResponse::Ok(Json(\n                CheckSshHostKeyResponseBody {\n                    remote_key_type: key.algorithm().as_str().into(),\n                    remote_key_base64: key.public_key_base64(),\n                },\n            ))),\n            Err(err) => Ok(CheckSshHostKeyResponse::Error(PlainText(format!(\n                \"{err:#}\"\n            )))),\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/ssh_keys.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse russh::keys::PublicKeyBase64;\nuse serde::Serialize;\nuse warpgate_common::WarpgateError;\nuse warpgate_common_http::AuthenticatedRequestContext;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[derive(Serialize, Object)]\nstruct SSHKey {\n    pub kind: String,\n    pub public_key_base64: String,\n}\n\n#[derive(ApiResponse)]\nenum GetSSHOwnKeysResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<SSHKey>>),\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/ssh/own-keys\",\n        method = \"get\",\n        operation_id = \"get_ssh_own_keys\"\n    )]\n    async fn api_ssh_get_own_keys(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetSSHOwnKeysResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let config = ctx.services.config.lock().await;\n        let keys =\n            warpgate_protocol_ssh::load_keys(&config, &ctx.services.global_params, \"client\")?;\n\n        let keys = keys\n            .into_iter()\n            .map(|k| SSHKey {\n                kind: k.algorithm().to_string(),\n                public_key_base64: k.public_key_base64(),\n            })\n            .collect();\n        Ok(GetSSHOwnKeysResponse::Ok(Json(keys)))\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/sso_credentials.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, ModelTrait, QueryFilter, Set};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, UserSsoCredential, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::SsoCredential;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\n#[derive(Object)]\nstruct ExistingSsoCredential {\n    id: Uuid,\n    provider: Option<String>,\n    email: String,\n}\n\n#[derive(Object)]\nstruct NewSsoCredential {\n    provider: Option<String>,\n    email: String,\n}\n\nimpl From<SsoCredential::Model> for ExistingSsoCredential {\n    fn from(credential: SsoCredential::Model) -> Self {\n        Self {\n            id: credential.id,\n            email: credential.email,\n            provider: credential.provider,\n        }\n    }\n}\n\nimpl From<&NewSsoCredential> for UserSsoCredential {\n    fn from(credential: &NewSsoCredential) -> Self {\n        Self {\n            email: credential.email.clone(),\n            provider: credential.provider.clone(),\n        }\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetSsoCredentialsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<ExistingSsoCredential>>),\n}\n\n#[derive(ApiResponse)]\nenum CreateSsoCredentialResponse {\n    #[oai(status = 201)]\n    Created(Json<ExistingSsoCredential>),\n}\n\n#[derive(ApiResponse)]\nenum UpdateSsoCredentialResponse {\n    #[oai(status = 200)]\n    Updated(Json<ExistingSsoCredential>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/sso\",\n        method = \"get\",\n        operation_id = \"get_sso_credentials\"\n    )]\n    async fn api_get_all(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetSsoCredentialsResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let objects = SsoCredential::Entity::find()\n            .filter(SsoCredential::Column::UserId.eq(*user_id))\n            .all(&*db)\n            .await?;\n\n        Ok(GetSsoCredentialsResponse::Ok(Json(\n            objects.into_iter().map(Into::into).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/users/:user_id/credentials/sso\",\n        method = \"post\",\n        operation_id = \"create_sso_credential\"\n    )]\n    async fn api_create(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<NewSsoCredential>,\n        user_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreateSsoCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let object = SsoCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(*user_id),\n            ..SsoCredential::ActiveModel::from(UserSsoCredential::from(&*body))\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        Ok(CreateSsoCredentialResponse::Created(Json(object.into())))\n    }\n}\n\n#[derive(ApiResponse)]\nenum DeleteCredentialResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(\n        path = \"/users/:user_id/credentials/sso/:id\",\n        method = \"put\",\n        operation_id = \"update_sso_credential\"\n    )]\n    async fn api_update(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<NewSsoCredential>,\n        user_id: Path<Uuid>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<UpdateSsoCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let model = SsoCredential::ActiveModel {\n            id: Set(id.0),\n            user_id: Set(*user_id),\n            ..<_>::from(UserSsoCredential::from(&*body))\n        }\n        .update(&*db)\n        .await;\n\n        match model {\n            Ok(model) => Ok(UpdateSsoCredentialResponse::Updated(Json(model.into()))),\n            Err(DbErr::RecordNotFound(_)) => Ok(UpdateSsoCredentialResponse::NotFound),\n            Err(e) => Err(e.into()),\n        }\n    }\n\n    #[oai(\n        path = \"/users/:user_id/credentials/sso/:id\",\n        method = \"delete\",\n        operation_id = \"delete_sso_credential\"\n    )]\n    async fn api_delete(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        user_id: Path<Uuid>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteCredentialResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(role) = SsoCredential::Entity::find_by_id(id.0)\n            .filter(SsoCredential::Column::UserId.eq(*user_id))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(DeleteCredentialResponse::NotFound);\n        };\n\n        role.delete(&*db).await?;\n        Ok(DeleteCredentialResponse::Deleted)\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/target_groups.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::prelude::Expr;\nuse sea_orm::{\n    ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set,\n};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::TargetGroup;\nuse warpgate_db_entities::TargetGroup::BootstrapThemeColor;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\n#[derive(Object)]\nstruct TargetGroupDataRequest {\n    name: String,\n    description: Option<String>,\n    color: Option<BootstrapThemeColor>,\n}\n\n#[derive(ApiResponse)]\nenum GetTargetGroupsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<TargetGroup::Model>>),\n}\n\n#[derive(ApiResponse)]\nenum CreateTargetGroupResponse {\n    #[oai(status = 201)]\n    Created(Json<TargetGroup::Model>),\n\n    #[oai(status = 409)]\n    Conflict(Json<String>),\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(\n        path = \"/target-groups\",\n        method = \"get\",\n        operation_id = \"list_target_groups\"\n    )]\n    async fn api_list_target_groups(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetTargetGroupsResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n        let groups = TargetGroup::Entity::find()\n            .order_by_asc(TargetGroup::Column::Name)\n            .all(&*db)\n            .await?;\n\n        Ok(GetTargetGroupsResponse::Ok(Json(groups)))\n    }\n\n    #[oai(\n        path = \"/target-groups\",\n        method = \"post\",\n        operation_id = \"create_target_group\"\n    )]\n    async fn api_create_target_group(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<TargetGroupDataRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreateTargetGroupResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::TargetsCreate)).await?;\n\n        if body.name.is_empty() {\n            return Ok(CreateTargetGroupResponse::BadRequest(Json(\"name\".into())));\n        }\n\n        let db = ctx.services.db.lock().await;\n        let existing = TargetGroup::Entity::find()\n            .filter(TargetGroup::Column::Name.eq(body.name.clone()))\n            .one(&*db)\n            .await?;\n        if existing.is_some() {\n            return Ok(CreateTargetGroupResponse::Conflict(Json(\n                \"Name already exists\".into(),\n            )));\n        }\n\n        let values = TargetGroup::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            name: Set(body.name.clone()),\n            description: Set(body.description.clone().unwrap_or_default()),\n            color: Set(body.color.clone()),\n        };\n\n        let group = values.insert(&*db).await?;\n\n        Ok(CreateTargetGroupResponse::Created(Json(group)))\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetTargetGroupResponse {\n    #[oai(status = 200)]\n    Ok(Json<TargetGroup::Model>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum UpdateTargetGroupResponse {\n    #[oai(status = 200)]\n    Ok(Json<TargetGroup::Model>),\n    #[oai(status = 400)]\n    BadRequest,\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum DeleteTargetGroupResponse {\n    #[oai(status = 204)]\n    Deleted,\n\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(\n        path = \"/target-groups/:id\",\n        method = \"get\",\n        operation_id = \"get_target_group\"\n    )]\n    async fn api_get_target_group(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetTargetGroupResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n        let group = TargetGroup::Entity::find_by_id(id.0).one(&*db).await?;\n\n        match group {\n            Some(group) => Ok(GetTargetGroupResponse::Ok(Json(group))),\n            None => Ok(GetTargetGroupResponse::NotFound),\n        }\n    }\n\n    #[oai(\n        path = \"/target-groups/:id\",\n        method = \"put\",\n        operation_id = \"update_target_group\"\n    )]\n    async fn api_update_target_group(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        body: Json<TargetGroupDataRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<UpdateTargetGroupResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::TargetsEdit)).await?;\n\n        if body.name.is_empty() {\n            return Ok(UpdateTargetGroupResponse::BadRequest);\n        }\n\n        let db = ctx.services.db.lock().await;\n        let group = TargetGroup::Entity::find_by_id(id.0).one(&*db).await?;\n\n        let Some(group) = group else {\n            return Ok(UpdateTargetGroupResponse::NotFound);\n        };\n\n        // Check if name is already taken by another group\n        let existing = TargetGroup::Entity::find()\n            .filter(TargetGroup::Column::Name.eq(body.name.clone()))\n            .filter(TargetGroup::Column::Id.ne(id.0))\n            .one(&*db)\n            .await?;\n        if existing.is_some() {\n            return Ok(UpdateTargetGroupResponse::BadRequest);\n        }\n\n        let mut group: TargetGroup::ActiveModel = group.into();\n        group.name = Set(body.name.clone());\n        group.description = Set(body.description.clone().unwrap_or_default());\n        group.color = Set(body.color.clone());\n\n        let group = group.update(&*db).await?;\n        Ok(UpdateTargetGroupResponse::Ok(Json(group)))\n    }\n\n    #[oai(\n        path = \"/target-groups/:id\",\n        method = \"delete\",\n        operation_id = \"delete_target_group\"\n    )]\n    async fn api_delete_target_group(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteTargetGroupResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::TargetsDelete)).await?;\n\n        let db = ctx.services.db.lock().await;\n        let group = TargetGroup::Entity::find_by_id(id.0).one(&*db).await?;\n\n        let Some(group) = group else {\n            return Ok(DeleteTargetGroupResponse::NotFound);\n        };\n\n        // First, unassign all targets from this group by setting their group_id to NULL\n        use warpgate_db_entities::Target;\n        Target::Entity::update_many()\n            .col_expr(Target::Column::GroupId, Expr::value(Option::<Uuid>::None))\n            .filter(Target::Column::GroupId.eq(id.0))\n            .exec(&*db)\n            .await?;\n\n        // Then delete the group\n        group.delete(&*db).await?;\n        Ok(DeleteTargetGroupResponse::Deleted)\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/targets.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::{Path, Query};\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::prelude::Expr;\nuse sea_orm::sea_query::SimpleExpr;\nuse sea_orm::{\n    ActiveModelTrait, ColumnTrait, Condition, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set,\n};\nuse uuid::Uuid;\nuse warpgate_common::{\n    AdminPermission, Role as RoleConfig, Target as TargetConfig, TargetOptions, TargetSSHOptions,\n    WarpgateError,\n};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::Target::TargetKind;\nuse warpgate_db_entities::{KnownHost, Role, Target, TargetRoleAssignment};\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\n#[derive(Object)]\nstruct TargetDataRequest {\n    name: String,\n    description: Option<String>,\n    options: TargetOptions,\n    rate_limit_bytes_per_second: Option<u32>,\n    group_id: Option<Uuid>,\n}\n\n#[derive(ApiResponse)]\nenum GetTargetsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<TargetConfig>>),\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(ApiResponse)]\nenum CreateTargetResponse {\n    #[oai(status = 201)]\n    Created(Json<TargetConfig>),\n\n    #[oai(status = 409)]\n    Conflict(Json<String>),\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(path = \"/targets\", method = \"get\", operation_id = \"get_targets\")]\n    async fn api_get_all_targets(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        search: Query<Option<String>>,\n        group_id: Query<Option<Uuid>>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetTargetsResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let mut targets = Target::Entity::find();\n\n        if let Some(ref search) = *search {\n            let search_pattern = format!(\"%{}%\", search.to_lowercase());\n            targets = targets\n                .filter(\n                    Condition::any()\n                        .add(Target::Column::Name.like(&search_pattern))\n                        .add(Target::Column::Description.like(&search_pattern)),\n                )\n                .order_by_asc({\n                    let case_expr: SimpleExpr = Expr::case(\n                        Expr::col((Target::Entity, Target::Column::Name)).like(&search_pattern),\n                        0,\n                    )\n                    .finally(1)\n                    .into();\n                    case_expr\n                })\n                .order_by_asc(Target::Column::Name);\n        } else {\n            targets = targets.order_by_asc(Target::Column::Name);\n        }\n\n        if let Some(group_id) = *group_id {\n            targets = targets.filter(Target::Column::GroupId.eq(group_id));\n        }\n\n        let targets = targets.all(&*db).await.map_err(WarpgateError::from)?;\n\n        let targets: Result<Vec<TargetConfig>, _> =\n            targets.into_iter().map(|t| t.try_into()).collect();\n        let targets = targets.map_err(WarpgateError::from)?;\n\n        Ok(GetTargetsResponse::Ok(Json(targets)))\n    }\n\n    #[oai(path = \"/targets\", method = \"post\", operation_id = \"create_target\")]\n    async fn api_create_target(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<TargetDataRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreateTargetResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::TargetsCreate)).await?;\n\n        if body.name.is_empty() {\n            return Ok(CreateTargetResponse::BadRequest(Json(\"name\".into())));\n        }\n\n        let db = ctx.services.db.lock().await;\n        let existing = Target::Entity::find()\n            .filter(Target::Column::Name.eq(body.name.clone()))\n            .one(&*db)\n            .await?;\n        if existing.is_some() {\n            return Ok(CreateTargetResponse::Conflict(Json(\n                \"Name already exists\".into(),\n            )));\n        }\n\n        let values = Target::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            name: Set(body.name.clone()),\n            description: Set(body.description.clone().unwrap_or_default()),\n            kind: Set((&body.options).into()),\n            options: Set(serde_json::to_value(body.options.clone()).map_err(WarpgateError::from)?),\n            rate_limit_bytes_per_second: Set(None),\n            group_id: Set(body.group_id),\n        };\n\n        let target = values.insert(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(CreateTargetResponse::Created(Json(\n            target.try_into().map_err(WarpgateError::from)?,\n        )))\n    }\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(ApiResponse)]\nenum GetTargetResponse {\n    #[oai(status = 200)]\n    Ok(Json<TargetConfig>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(ApiResponse)]\nenum UpdateTargetResponse {\n    #[oai(status = 200)]\n    Ok(Json<TargetConfig>),\n    #[oai(status = 400)]\n    BadRequest,\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum DeleteTargetResponse {\n    #[oai(status = 204)]\n    Deleted,\n\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum TargetKnownSshHostKeysResponse {\n    #[oai(status = 200)]\n    Found(Json<Vec<KnownHost::Model>>),\n\n    #[oai(status = 400)]\n    InvalidType,\n\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(path = \"/targets/:id\", method = \"get\", operation_id = \"get_target\")]\n    async fn api_get_target(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetTargetResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(GetTargetResponse::NotFound);\n        };\n\n        Ok(GetTargetResponse::Ok(Json(target.try_into()?)))\n    }\n\n    #[oai(path = \"/targets/:id\", method = \"put\", operation_id = \"update_target\")]\n    async fn api_update_target(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<TargetDataRequest>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<UpdateTargetResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::TargetsEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(UpdateTargetResponse::NotFound);\n        };\n\n        if target.kind != (&body.options).into() {\n            return Ok(UpdateTargetResponse::BadRequest);\n        }\n\n        let services = &ctx.services;\n        let mut model: Target::ActiveModel = target.into();\n        model.name = Set(body.name.clone());\n        model.description = Set(body.description.clone().unwrap_or_default());\n        model.options =\n            Set(serde_json::to_value(body.options.clone()).map_err(WarpgateError::from)?);\n        model.rate_limit_bytes_per_second = Set(body.rate_limit_bytes_per_second.map(|x| x as i64));\n        model.group_id = Set(body.group_id);\n        let target = model.update(&*db).await?;\n\n        drop(db);\n\n        services\n            .rate_limiter_registry\n            .lock()\n            .await\n            .apply_new_rate_limits(&mut *services.state.lock().await)\n            .await?;\n\n        Ok(UpdateTargetResponse::Ok(Json(\n            target.try_into().map_err(WarpgateError::from)?,\n        )))\n    }\n\n    #[oai(\n        path = \"/targets/:id\",\n        method = \"delete\",\n        operation_id = \"delete_target\"\n    )]\n    async fn api_delete_target(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteTargetResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::TargetsDelete)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(DeleteTargetResponse::NotFound);\n        };\n\n        TargetRoleAssignment::Entity::delete_many()\n            .filter(TargetRoleAssignment::Column::TargetId.eq(target.id))\n            .exec(&*db)\n            .await?;\n\n        if target.kind == TargetKind::Ssh {\n            let options: TargetOptions = serde_json::from_value(target.options.clone())?;\n            if let TargetOptions::Ssh(ssh_options) = options {\n                use warpgate_db_entities::KnownHost;\n                KnownHost::Entity::delete_many()\n                    .filter(KnownHost::Column::Host.eq(&ssh_options.host))\n                    .filter(KnownHost::Column::Port.eq(ssh_options.port as i32))\n                    .exec(&*db)\n                    .await?;\n            }\n        }\n\n        target.delete(&*db).await?;\n        Ok(DeleteTargetResponse::Deleted)\n    }\n\n    #[oai(\n        path = \"/targets/:id/known-ssh-host-keys\",\n        method = \"get\",\n        operation_id = \"get_ssh_target_known_ssh_host_keys\"\n    )]\n    async fn get_ssh_target_known_ssh_host_keys(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<TargetKnownSshHostKeysResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::TargetsEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(target) = Target::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(TargetKnownSshHostKeysResponse::NotFound);\n        };\n\n        let target: TargetConfig = target.try_into()?;\n\n        let options: TargetSSHOptions = match target.options {\n            TargetOptions::Ssh(x) => x,\n            _ => return Ok(TargetKnownSshHostKeysResponse::InvalidType),\n        };\n\n        let known_hosts = KnownHost::Entity::find()\n            .filter(\n                KnownHost::Column::Host\n                    .eq(&options.host)\n                    .and(KnownHost::Column::Port.eq(options.port)),\n            )\n            .all(&*db)\n            .await?;\n\n        Ok(TargetKnownSshHostKeysResponse::Found(Json(known_hosts)))\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetTargetRolesResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<RoleConfig>>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum AddTargetRoleResponse {\n    #[oai(status = 201)]\n    Created,\n    #[oai(status = 409)]\n    AlreadyExists,\n}\n\n#[derive(ApiResponse)]\nenum DeleteTargetRoleResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct RolesApi;\n\n#[OpenApi]\nimpl RolesApi {\n    #[oai(\n        path = \"/targets/:id/roles\",\n        method = \"get\",\n        operation_id = \"get_target_roles\"\n    )]\n    async fn api_get_target_roles(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetTargetRolesResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some((_, roles)) = Target::Entity::find_by_id(*id)\n            .find_with_related(Role::Entity)\n            .all(&*db)\n            .await\n            .map(|x| x.into_iter().next())\n            .map_err(WarpgateError::from)?\n        else {\n            return Ok(GetTargetRolesResponse::NotFound);\n        };\n\n        Ok(GetTargetRolesResponse::Ok(Json(\n            roles.into_iter().map(|x| x.into()).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/targets/:id/roles/:role_id\",\n        method = \"post\",\n        operation_id = \"add_target_role\"\n    )]\n    async fn api_add_target_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        role_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<AddTargetRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AccessRolesAssign)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        if !TargetRoleAssignment::Entity::find()\n            .filter(TargetRoleAssignment::Column::TargetId.eq(id.0))\n            .filter(TargetRoleAssignment::Column::RoleId.eq(role_id.0))\n            .all(&*db)\n            .await\n            .map_err(WarpgateError::from)?\n            .is_empty()\n        {\n            return Ok(AddTargetRoleResponse::AlreadyExists);\n        }\n\n        let values = TargetRoleAssignment::ActiveModel {\n            target_id: Set(id.0),\n            role_id: Set(role_id.0),\n            ..Default::default()\n        };\n\n        values.insert(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(AddTargetRoleResponse::Created)\n    }\n\n    #[oai(\n        path = \"/targets/:id/roles/:role_id\",\n        method = \"delete\",\n        operation_id = \"delete_target_role\"\n    )]\n    async fn api_delete_target_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        role_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteTargetRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AccessRolesAssign)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(model) = TargetRoleAssignment::Entity::find()\n            .filter(TargetRoleAssignment::Column::TargetId.eq(id.0))\n            .filter(TargetRoleAssignment::Column::RoleId.eq(role_id.0))\n            .one(&*db)\n            .await\n            .map_err(WarpgateError::from)?\n        else {\n            return Ok(DeleteTargetRoleResponse::NotFound);\n        };\n\n        model.delete(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(DeleteTargetRoleResponse::Deleted)\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/tickets_detail.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::{ApiResponse, OpenApi};\nuse sea_orm::{EntityTrait, ModelTrait};\nuse uuid::Uuid;\nuse warpgate_common::{AdminPermission, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[derive(ApiResponse)]\nenum DeleteTicketResponse {\n    #[oai(status = 204)]\n    Deleted,\n\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/tickets/:id\",\n        method = \"delete\",\n        operation_id = \"delete_ticket\"\n    )]\n    async fn api_delete_ticket(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteTicketResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::TicketsDelete)).await?;\n\n        use warpgate_db_entities::Ticket;\n        let db = ctx.services.db.lock().await;\n\n        let ticket = Ticket::Entity::find_by_id(id.0).one(&*db).await?;\n\n        match ticket {\n            Some(ticket) => {\n                ticket.delete(&*db).await?;\n                Ok(DeleteTicketResponse::Deleted)\n            }\n            None => Ok(DeleteTicketResponse::NotFound),\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/tickets_list.rs",
    "content": "use anyhow::Context;\nuse chrono::{DateTime, Utc};\nuse poem::web::Data;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::ActiveValue::Set;\nuse sea_orm::{ActiveModelTrait, EntityTrait};\nuse uuid::Uuid;\nuse warpgate_common::helpers::hash::generate_ticket_secret;\nuse warpgate_common::{AdminPermission, WarpgateError};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::Ticket;\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\npub struct Api;\n\n#[derive(ApiResponse)]\nenum GetTicketsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<Ticket::Model>>),\n}\n\n#[derive(Object)]\nstruct CreateTicketRequest {\n    username: String,\n    target_name: String,\n    expiry: Option<DateTime<Utc>>,\n    number_of_uses: Option<i16>,\n    description: Option<String>,\n}\n\n#[derive(Object)]\nstruct TicketAndSecret {\n    ticket: Ticket::Model,\n    secret: String,\n}\n\n#[derive(ApiResponse)]\nenum CreateTicketResponse {\n    #[oai(status = 201)]\n    Created(Json<TicketAndSecret>),\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(path = \"/tickets\", method = \"get\", operation_id = \"get_tickets\")]\n    async fn api_get_all_tickets(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetTicketsResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        use warpgate_db_entities::Ticket;\n\n        let db = ctx.services.db.lock().await;\n        let tickets = Ticket::Entity::find().all(&*db).await?;\n        Ok(GetTicketsResponse::Ok(Json(tickets)))\n    }\n\n    #[oai(path = \"/tickets\", method = \"post\", operation_id = \"create_ticket\")]\n    async fn api_create_ticket(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<CreateTicketRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> poem::Result<CreateTicketResponse> {\n        require_admin_permission(&ctx, Some(AdminPermission::TicketsCreate)).await?;\n\n        use warpgate_db_entities::Ticket;\n\n        if body.username.is_empty() {\n            return Ok(CreateTicketResponse::BadRequest(Json(\"username\".into())));\n        }\n        if body.target_name.is_empty() {\n            return Ok(CreateTicketResponse::BadRequest(Json(\"target_name\".into())));\n        }\n\n        let db = ctx.services.db.lock().await;\n        let secret = generate_ticket_secret();\n        let values = Ticket::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            secret: Set(secret.expose_secret().to_string()),\n            username: Set(body.username.clone()),\n            target: Set(body.target_name.clone()),\n            created: Set(chrono::Utc::now()),\n            expiry: Set(body.expiry),\n            uses_left: Set(body.number_of_uses),\n            description: Set(body.description.clone().unwrap_or_default()),\n        };\n\n        let ticket = values.insert(&*db).await.context(\"Error saving ticket\")?;\n\n        Ok(CreateTicketResponse::Created(Json(TicketAndSecret {\n            secret: secret.expose_secret().to_string(),\n            ticket,\n        })))\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/api/users.rs",
    "content": "use poem::web::Data;\nuse poem_openapi::param::{Path, Query};\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{\n    ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, Set,\n};\nuse tracing::warn;\nuse uuid::Uuid;\nuse warpgate_common::{\n    AdminPermission, AdminRole as AdminRoleConfig, Role as RoleConfig, User as UserConfig,\n    UserRequireCredentialsPolicy, WarpgateError,\n};\nuse warpgate_common_http::AuthenticatedRequestContext;\nuse warpgate_db_entities::{AdminRole, Role, User, UserRoleAssignment};\n\nuse super::AnySecurityScheme;\nuse crate::api::common::require_admin_permission;\n\n#[derive(Object)]\nstruct CreateUserRequest {\n    username: String,\n    description: Option<String>,\n}\n\n#[derive(Object)]\nstruct UserDataRequest {\n    username: String,\n    credential_policy: Option<UserRequireCredentialsPolicy>,\n    description: Option<String>,\n    rate_limit_bytes_per_second: Option<u32>,\n}\n\n#[derive(ApiResponse)]\nenum GetUsersResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<UserConfig>>),\n}\n#[derive(ApiResponse)]\nenum CreateUserResponse {\n    #[oai(status = 201)]\n    Created(Json<UserConfig>),\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\npub struct ListApi;\n\n#[OpenApi]\nimpl ListApi {\n    #[oai(path = \"/users\", method = \"get\", operation_id = \"get_users\")]\n    async fn api_get_all_users(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        search: Query<Option<String>>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetUsersResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let mut users = User::Entity::find().order_by_asc(User::Column::Username);\n\n        if let Some(ref search) = *search {\n            let search = format!(\"%{search}%\");\n            users = users.filter(User::Column::Username.like(search));\n        }\n\n        let users = users.all(&*db).await.map_err(WarpgateError::from)?;\n\n        let users: Vec<UserConfig> = users\n            .into_iter()\n            .map(UserConfig::try_from)\n            .collect::<Result<Vec<UserConfig>, _>>()?;\n\n        Ok(GetUsersResponse::Ok(Json(users)))\n    }\n\n    #[oai(path = \"/users\", method = \"post\", operation_id = \"create_user\")]\n    async fn api_create_user(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<CreateUserRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreateUserResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersCreate)).await?;\n\n        if body.username.is_empty() {\n            return Ok(CreateUserResponse::BadRequest(Json(\"name\".into())));\n        }\n\n        let db = ctx.services.db.lock().await;\n\n        let values = User::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            username: Set(body.username.clone()),\n            credential_policy: Set(\n                serde_json::to_value(UserRequireCredentialsPolicy::default())\n                    .map_err(WarpgateError::from)?,\n            ),\n            description: Set(body.description.clone().unwrap_or_default()),\n            rate_limit_bytes_per_second: Set(None),\n            ldap_server_id: Set(None),\n            ldap_object_uuid: Set(None),\n        };\n\n        let user = values.insert(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(CreateUserResponse::Created(Json(user.try_into()?)))\n    }\n}\n\n#[derive(ApiResponse)]\n#[allow(clippy::large_enum_variant)]\nenum GetUserResponse {\n    #[oai(status = 200)]\n    Ok(Json<UserConfig>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\n#[allow(clippy::large_enum_variant)]\nenum UpdateUserResponse {\n    #[oai(status = 200)]\n    Ok(Json<UserConfig>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum DeleteUserResponse {\n    #[oai(status = 204)]\n    Deleted,\n\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum UnlinkUserFromLdapResponse {\n    #[oai(status = 200)]\n    Ok(Json<UserConfig>),\n\n    #[oai(status = 404)]\n    NotFound,\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\n#[derive(ApiResponse)]\nenum AutoLinkUserToLdapResponse {\n    #[oai(status = 200)]\n    Ok(Json<UserConfig>),\n\n    #[oai(status = 404)]\n    NotFound,\n\n    #[oai(status = 400)]\n    BadRequest(Json<String>),\n}\n\npub struct DetailApi;\n\n#[OpenApi]\nimpl DetailApi {\n    #[oai(path = \"/users/:id\", method = \"get\", operation_id = \"get_user\")]\n    async fn api_get_user(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetUserResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(GetUserResponse::NotFound);\n        };\n\n        Ok(GetUserResponse::Ok(Json(user.try_into()?)))\n    }\n\n    #[oai(path = \"/users/:id\", method = \"put\", operation_id = \"update_user\")]\n    async fn api_update_user(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<UserDataRequest>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<UpdateUserResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(UpdateUserResponse::NotFound);\n        };\n\n        let mut model: User::ActiveModel = user.into();\n        model.username = Set(body.username.clone());\n        model.description = Set(body.description.clone().unwrap_or_default());\n        model.credential_policy =\n            Set(serde_json::to_value(body.credential_policy.clone())\n                .map_err(WarpgateError::from)?);\n        model.rate_limit_bytes_per_second = Set(body.rate_limit_bytes_per_second.map(|x| x as i64));\n        let user = model.update(&*db).await?;\n\n        drop(db);\n\n        ctx.services\n            .rate_limiter_registry\n            .lock()\n            .await\n            .apply_new_rate_limits(&mut *ctx.services.state.lock().await)\n            .await?;\n\n        Ok(UpdateUserResponse::Ok(Json(user.try_into()?)))\n    }\n\n    #[oai(path = \"/users/:id\", method = \"delete\", operation_id = \"delete_user\")]\n    async fn api_delete_user(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteUserResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersDelete)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(DeleteUserResponse::NotFound);\n        };\n\n        UserRoleAssignment::Entity::delete_many()\n            .filter(UserRoleAssignment::Column::UserId.eq(user.id))\n            .exec(&*db)\n            .await?;\n\n        user.delete(&*db).await?;\n        Ok(DeleteUserResponse::Deleted)\n    }\n\n    #[oai(\n        path = \"/users/:id/ldap-link/unlink\",\n        method = \"post\",\n        operation_id = \"unlink_user_from_ldap\"\n    )]\n    async fn api_unlink_user_from_ldap(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<UnlinkUserFromLdapResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(UnlinkUserFromLdapResponse::NotFound);\n        };\n\n        if user.ldap_server_id.is_none() {\n            return Ok(UnlinkUserFromLdapResponse::BadRequest(Json(\n                \"User is not linked to LDAP\".to_string(),\n            )));\n        }\n\n        let mut model: User::ActiveModel = user.into();\n        model.ldap_server_id = Set(None);\n        model.ldap_object_uuid = Set(None);\n        let user = model.update(&*db).await?;\n\n        Ok(UnlinkUserFromLdapResponse::Ok(Json(user.try_into()?)))\n    }\n\n    #[oai(\n        path = \"/users/:id/ldap-link/auto-link\",\n        method = \"post\",\n        operation_id = \"auto_link_user_to_ldap\"\n    )]\n    async fn api_auto_link_user_to_ldap(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<AutoLinkUserToLdapResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::UsersEdit)).await?;\n\n        use warpgate_db_entities::LdapServer;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(user) = User::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(AutoLinkUserToLdapResponse::NotFound);\n        };\n\n        if user.ldap_server_id.is_some() {\n            return Ok(AutoLinkUserToLdapResponse::BadRequest(Json(\n                \"User is already linked to LDAP\".to_string(),\n            )));\n        }\n\n        // Get all enabled LDAP servers\n        let ldap_servers: Vec<LdapServer::Model> = LdapServer::Entity::find()\n            .filter(LdapServer::Column::Enabled.eq(true))\n            .all(&*db)\n            .await?;\n\n        if ldap_servers.is_empty() {\n            return Ok(AutoLinkUserToLdapResponse::BadRequest(Json(\n                \"No enabled LDAP servers configured\".to_string(),\n            )));\n        }\n\n        // Try to find user in LDAP servers using username as email\n        let username = &user.username;\n        let mut ldap_server_id = None;\n        let mut ldap_object_uuid = None;\n\n        for ldap_server in ldap_servers {\n            let ldap_config = warpgate_ldap::LdapConfig::try_from(&ldap_server)?;\n\n            match warpgate_ldap::find_user_by_username(&ldap_config, username).await {\n                Ok(Some(ldap_user)) => {\n                    ldap_server_id = Some(ldap_server.id);\n                    ldap_object_uuid = Some(ldap_user.object_uuid);\n                    break;\n                }\n                Ok(None) => continue,\n                Err(e) => {\n                    warn!(\"Error searching for LDAP user in {}: {e}\", ldap_server.name);\n                    continue;\n                }\n            }\n        }\n\n        if ldap_server_id.is_none() {\n            return Ok(AutoLinkUserToLdapResponse::BadRequest(Json(format!(\n                \"No LDAP user found with username: {username}\",\n            ))));\n        }\n\n        let mut model: User::ActiveModel = user.into();\n        model.ldap_server_id = Set(ldap_server_id);\n        model.ldap_object_uuid = Set(ldap_object_uuid);\n        let user = model.update(&*db).await?;\n\n        Ok(AutoLinkUserToLdapResponse::Ok(Json(user.try_into()?)))\n    }\n}\n\n#[derive(ApiResponse)]\nenum GetUserRolesResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<RoleConfig>>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum AddUserRoleResponse {\n    #[oai(status = 201)]\n    Created,\n    #[oai(status = 409)]\n    AlreadyExists,\n}\n\n#[derive(ApiResponse)]\nenum DeleteUserRoleResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum GetUserAdminRolesResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<AdminRoleConfig>>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum AddUserAdminRoleResponse {\n    #[oai(status = 201)]\n    Created,\n    #[oai(status = 409)]\n    AlreadyExists,\n}\n\n#[derive(ApiResponse)]\nenum DeleteUserAdminRoleResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub struct RolesApi;\n\n#[OpenApi]\nimpl RolesApi {\n    #[oai(\n        path = \"/users/:id/roles\",\n        method = \"get\",\n        operation_id = \"get_user_roles\"\n    )]\n    async fn api_get_user_roles(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetUserRolesResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some((_, roles)) = User::Entity::find_by_id(*id)\n            .find_with_related(Role::Entity)\n            .all(&*db)\n            .await\n            .map(|x| x.into_iter().next())\n            .map_err(WarpgateError::from)?\n        else {\n            return Ok(GetUserRolesResponse::NotFound);\n        };\n\n        Ok(GetUserRolesResponse::Ok(Json(\n            roles.into_iter().map(|x| x.into()).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/users/:id/roles/:role_id\",\n        method = \"post\",\n        operation_id = \"add_user_role\"\n    )]\n    async fn api_add_user_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        role_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<AddUserRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AccessRolesAssign)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        if !UserRoleAssignment::Entity::find()\n            .filter(UserRoleAssignment::Column::UserId.eq(id.0))\n            .filter(UserRoleAssignment::Column::RoleId.eq(role_id.0))\n            .all(&*db)\n            .await\n            .map_err(WarpgateError::from)?\n            .is_empty()\n        {\n            return Ok(AddUserRoleResponse::AlreadyExists);\n        }\n\n        let values = UserRoleAssignment::ActiveModel {\n            user_id: Set(id.0),\n            role_id: Set(role_id.0),\n            ..Default::default()\n        };\n\n        values.insert(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(AddUserRoleResponse::Created)\n    }\n\n    #[oai(\n        path = \"/users/:id/roles/:role_id\",\n        method = \"delete\",\n        operation_id = \"delete_user_role\"\n    )]\n    async fn api_delete_user_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        role_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteUserRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AccessRolesAssign)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(_user) = User::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(DeleteUserRoleResponse::NotFound);\n        };\n\n        let Some(_role) = Role::Entity::find_by_id(role_id.0).one(&*db).await? else {\n            return Ok(DeleteUserRoleResponse::NotFound);\n        };\n\n        let Some(model) = UserRoleAssignment::Entity::find()\n            .filter(UserRoleAssignment::Column::UserId.eq(id.0))\n            .filter(UserRoleAssignment::Column::RoleId.eq(role_id.0))\n            .one(&*db)\n            .await\n            .map_err(WarpgateError::from)?\n        else {\n            return Ok(DeleteUserRoleResponse::NotFound);\n        };\n\n        model.delete(&*db).await.map_err(WarpgateError::from)?;\n\n        Ok(DeleteUserRoleResponse::Deleted)\n    }\n\n    #[oai(\n        path = \"/users/:id/admin-roles\",\n        method = \"get\",\n        operation_id = \"get_user_admin_roles\"\n    )]\n    async fn api_get_user_admin_roles(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetUserAdminRolesResponse, WarpgateError> {\n        require_admin_permission(&ctx, None).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some((_, roles)) = User::Entity::find_by_id(*id)\n            .find_with_related(AdminRole::Entity)\n            .all(&*db)\n            .await\n            .map(|x| x.into_iter().next())\n            .map_err(WarpgateError::from)?\n        else {\n            return Ok(GetUserAdminRolesResponse::NotFound);\n        };\n\n        Ok(GetUserAdminRolesResponse::Ok(Json(\n            roles.into_iter().map(|x| x.into()).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/users/:id/admin-roles/:role_id\",\n        method = \"post\",\n        operation_id = \"add_user_admin_role\"\n    )]\n    async fn api_add_user_admin_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        role_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<AddUserAdminRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        if !warpgate_db_entities::UserAdminRoleAssignment::Entity::find()\n            .filter(warpgate_db_entities::UserAdminRoleAssignment::Column::UserId.eq(id.0))\n            .filter(\n                warpgate_db_entities::UserAdminRoleAssignment::Column::AdminRoleId.eq(role_id.0),\n            )\n            .all(&*db)\n            .await\n            .map_err(WarpgateError::from)?\n            .is_empty()\n        {\n            return Ok(AddUserAdminRoleResponse::AlreadyExists);\n        }\n\n        let values = warpgate_db_entities::UserAdminRoleAssignment::ActiveModel {\n            user_id: Set(id.0),\n            admin_role_id: Set(role_id.0),\n            ..Default::default()\n        };\n\n        values.insert(&*db).await.map_err(WarpgateError::from)?;\n        Ok(AddUserAdminRoleResponse::Created)\n    }\n\n    #[oai(\n        path = \"/users/:id/admin-roles/:role_id\",\n        method = \"delete\",\n        operation_id = \"delete_user_admin_role\"\n    )]\n    async fn api_delete_user_admin_role(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        role_id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteUserAdminRoleResponse, WarpgateError> {\n        require_admin_permission(&ctx, Some(AdminPermission::AdminRolesManage)).await?;\n\n        let db = ctx.services.db.lock().await;\n\n        let Some(_user) = User::Entity::find_by_id(id.0).one(&*db).await? else {\n            return Ok(DeleteUserAdminRoleResponse::NotFound);\n        };\n\n        let Some(_role) = AdminRole::Entity::find_by_id(role_id.0).one(&*db).await? else {\n            return Ok(DeleteUserAdminRoleResponse::NotFound);\n        };\n\n        let Some(model) = warpgate_db_entities::UserAdminRoleAssignment::Entity::find()\n            .filter(warpgate_db_entities::UserAdminRoleAssignment::Column::UserId.eq(id.0))\n            .filter(\n                warpgate_db_entities::UserAdminRoleAssignment::Column::AdminRoleId.eq(role_id.0),\n            )\n            .one(&*db)\n            .await\n            .map_err(WarpgateError::from)?\n        else {\n            return Ok(DeleteUserAdminRoleResponse::NotFound);\n        };\n\n        model.delete(&*db).await.map_err(WarpgateError::from)?;\n        Ok(DeleteUserAdminRoleResponse::Deleted)\n    }\n}\n"
  },
  {
    "path": "warpgate-admin/src/lib.rs",
    "content": "pub mod api;\nuse poem::{IntoEndpoint, Route};\nuse poem_openapi::OpenApiService;\nuse warpgate_common::version::warpgate_version;\n\npub fn admin_api_app() -> impl IntoEndpoint {\n    let api_service =\n        OpenApiService::new(crate::api::get(), \"Warpgate admin API\", warpgate_version())\n            .server(\"/@warpgate/admin/api\");\n\n    let ui = api_service.stoplight_elements();\n    let spec = api_service.spec_endpoint();\n\n    Route::new()\n        .nest(\"\", api_service)\n        .nest(\"/playground\", ui)\n        .nest(\"/openapi.json\", spec)\n        .at(\n            \"/recordings/:id/cast\",\n            crate::api::recordings_detail::api_get_recording_cast,\n        )\n        .at(\n            \"/recordings/:id/stream\",\n            crate::api::recordings_detail::api_get_recording_stream,\n        )\n        .at(\n            \"/recordings/:id/tcpdump\",\n            crate::api::recordings_detail::api_get_recording_tcpdump,\n        )\n        .at(\n            \"/sessions/changes\",\n            crate::api::sessions_list::api_get_sessions_changes_stream,\n        )\n}\n"
  },
  {
    "path": "warpgate-admin/src/main.rs",
    "content": "mod api;\nuse poem_openapi::OpenApiService;\nuse regex::Regex;\nuse warpgate_common::version::warpgate_version;\n\n#[allow(clippy::unwrap_used)]\npub fn main() {\n    let api_service = OpenApiService::new(api::get(), \"Warpgate Web Admin\", warpgate_version())\n        .server(\"/@warpgate/admin/api\");\n\n    let spec = api_service.spec();\n    let re = Regex::new(r\"PaginatedResponse<(?P<name>\\w+)>\").unwrap();\n    let spec = re.replace_all(&spec, \"Paginated$name\");\n\n    println!(\"{spec}\");\n}\n"
  },
  {
    "path": "warpgate-ca/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-ca\"\nversion = \"0.22.0\"\n\n[dependencies]\nbytes.workspace = true\nthiserror.workspace = true\ntokio.workspace = true\ndata-encoding.workspace = true\ntracing.workspace = true\nrcgen.workspace = true\nx509-parser.workspace = true\nx509-cert = { version = \"0.2\", features = [\"builder\", \"signature\"] }\naws-lc-rs = \"1\"\nder = \"0.7\"\npem = \"3.0\"\nspki = \"0.7\"\nconst-oid = \"0.9\"\nuuid.workspace = true\n\nhex.workspace = true\n"
  },
  {
    "path": "warpgate-ca/src/error.rs",
    "content": "use std::error::Error;\n\nuse x509_parser::error::{PEMError, X509Error};\n\n#[derive(thiserror::Error, Debug)]\npub enum CaError {\n    #[error(\"x509-cert: {0}\")]\n    X509Cert(#[from] x509_cert::builder::Error),\n    #[error(\"aws-lc-rs: {0}\")]\n    AwsLcRs(#[from] aws_lc_rs::error::Unspecified),\n    #[error(\"DER: {0}\")]\n    Der(#[from] der::Error),\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(\"ASN.1 X509 {0}\")]\n    Asn1X509(#[from] x509_parser::asn1_rs::Err<X509Error>),\n    #[error(\"ASN.1 PEM: {0}\")]\n    Asn1Pem(#[from] x509_parser::asn1_rs::Err<PEMError>),\n    #[error(\"rcgen: {0}\")]\n    RcGen(#[from] rcgen::Error),\n    #[error(\"Invalid key format\")]\n    InvalidKeyFormat,\n    #[error(\"Invalid certificate format\")]\n    InvalidCertificateFormat,\n    #[error(transparent)]\n    Other(Box<dyn Error + Send + Sync>),\n}\n"
  },
  {
    "path": "warpgate-ca/src/lib.rs",
    "content": "use std::time::{Duration, SystemTime};\n\nuse aws_lc_rs::digest;\nuse aws_lc_rs::error::KeyRejected;\nuse aws_lc_rs::signature::{EcdsaKeyPair, ECDSA_P384_SHA3_384_ASN1_SIGNING};\nuse data_encoding::BASE64;\nuse der::{Decode, DecodePem, Encode};\nuse spki::{AlgorithmIdentifier, SubjectPublicKeyInfo};\nuse uuid::Uuid;\nuse x509_cert::serial_number::SerialNumber;\nuse x509_cert::time::Validity;\nuse x509_cert::{Certificate, TbsCertificate, Version};\nuse x509_parser::pem::parse_x509_pem;\n\nmod error;\npub use error::CaError;\n\nimpl From<KeyRejected> for CaError {\n    fn from(err: KeyRejected) -> Self {\n        CaError::Other(Box::new(std::io::Error::new(\n            std::io::ErrorKind::InvalidData,\n            format!(\"Key rejected: {:?}\", err),\n        )))\n    }\n}\n\n/// A certificate and its associated private key\n#[derive(Debug)]\npub struct CertifiedKey {\n    /// The X.509 certificate\n    pub certificate: Certificate,\n    /// The private key in DER format\n    pub private_key_der: Vec<u8>,\n}\n\nimpl CertifiedKey {\n    /// Get the certificate as PEM-encoded string\n    pub fn certificate_pem(&self) -> Result<String, CaError> {\n        let der = self.certificate.to_der()?;\n        let pem_data = pem::Pem::new(\"CERTIFICATE\", der);\n        Ok(pem::encode(&pem_data))\n    }\n\n    /// Get the private key as PEM-encoded string\n    pub fn private_key_pem(&self) -> String {\n        let pem_data = pem::Pem::new(\"EC PRIVATE KEY\", self.private_key_der.clone());\n        pem::encode(&pem_data)\n    }\n}\n\npub fn generate_root_certificate_rcgen() -> Result<(String, String), CaError> {\n    use rcgen::{CertificateParams, DistinguishedName, IsCa, KeyPair};\n\n    // Create a new key pair\n    let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P384_SHA384)?;\n\n    // Create certificate parameters\n    let mut params = CertificateParams::new(vec![])?;\n\n    // Set up distinguished name\n    let mut dn = DistinguishedName::new();\n    dn.push(rcgen::DnType::CommonName, \"Warpgate Instance CA\");\n    params.distinguished_name = dn;\n\n    params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained);\n    params.not_before = SystemTime::now().into();\n    params.not_after = (SystemTime::now() + Duration::from_secs(99 * 365 * 24 * 60 * 60)).into();\n\n    // Generate the certificate\n    let cert = params.self_signed(&key_pair)?;\n\n    Ok((cert.pem(), key_pair.serialize_pem()))\n}\n\npub fn deserialize_certificate(pem: &str) -> Result<Certificate, CaError> {\n    let (_, pem_cert) = parse_x509_pem(pem.as_bytes())?;\n\n    Ok(Certificate::from_der(&pem_cert.contents)?)\n}\n\npub fn serialize_certificate_serial(cert: &Certificate) -> String {\n    BASE64.encode(cert.tbs_certificate.serial_number.as_bytes())\n}\n\npub fn certificate_sha256_hex_fingerprint(cert: &Certificate) -> Result<String, CaError> {\n    let der = cert.to_der()?;\n    let digest = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, &der);\n    Ok(hex::encode(digest.as_ref()))\n}\n\n/// Deserialize a CA certificate and private key from PEM format\npub fn deserialize_ca(\n    certificate_pem: &str,\n    private_key_pem: &str,\n) -> Result<CertifiedKey, CaError> {\n    let certificate = deserialize_certificate(certificate_pem)?;\n\n    // Parse the private key PEM\n    let key_pem = pem::parse(private_key_pem).map_err(|e| {\n        CaError::Other(Box::new(std::io::Error::new(\n            std::io::ErrorKind::InvalidData,\n            format!(\"Failed to parse private key PEM: {:?}\", e),\n        )))\n    })?;\n\n    // Validate it's a private key by checking the tag\n    let tag = key_pem.tag();\n    let ec_private_key_tag = \"PRIVATE KEY\";\n    if tag != ec_private_key_tag {\n        return Err(CaError::InvalidKeyFormat);\n    }\n\n    Ok(CertifiedKey {\n        certificate,\n        private_key_der: key_pem.contents().to_vec(),\n    })\n}\n\n/// Issue a client certificate signed by the CA\npub fn issue_client_certificate(\n    ca: &CertifiedKey,\n    subject_name: &str,\n    public_key_pem: &str,\n    user_id: Uuid,\n) -> Result<Certificate, CaError> {\n    use const_oid::db::{rfc4519, rfc5280, rfc5912};\n    use der::asn1::{OctetString, SetOfVec, Utf8StringRef};\n    use x509_cert::attr::AttributeTypeAndValue;\n    use x509_cert::ext::pkix::{BasicConstraints, KeyUsage, KeyUsages};\n    use x509_cert::ext::Extension;\n    use x509_cert::name::{RdnSequence, RelativeDistinguishedName};\n\n    let ca_key_pair =\n        EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA3_384_ASN1_SIGNING, &ca.private_key_der)?;\n\n    let validity = {\n        let validity = Duration::from_secs(365 * 24 * 60 * 60);\n        let now = SystemTime::now();\n        Validity {\n            not_before: now.try_into()?,\n            not_after: (now + validity).try_into()?,\n        }\n    };\n\n    let subject = {\n        let name_attrs = vec![AttributeTypeAndValue {\n            oid: rfc4519::UID,\n            value: Utf8StringRef::new(&user_id.to_string())?.into(),\n        }];\n\n        let rdn = RelativeDistinguishedName::from(SetOfVec::try_from(name_attrs)?);\n        RdnSequence::from(vec![rdn])\n    };\n\n    // Get the issuer name from the CA certificate\n    let issuer = ca.certificate.tbs_certificate.issuer.clone();\n\n    let serial_number = {\n        // Generate a unique serial number\n        let mut hasher = digest::Context::new(&digest::SHA256);\n        hasher.update(subject_name.as_bytes());\n        hasher.update(\n            &SystemTime::now()\n                .duration_since(SystemTime::UNIX_EPOCH)\n                .unwrap_or_default()\n                .as_nanos()\n                .to_le_bytes(),\n        );\n        let hash = hasher.finish();\n        #[allow(clippy::indexing_slicing, reason = \"length known\")]\n        let serial_bytes = &hash.as_ref()[..8]; // Use first 8 bytes\n        SerialNumber::new(serial_bytes)?\n    };\n\n    let public_key = SubjectPublicKeyInfo::from_pem(public_key_pem)?;\n\n    let tbs_cert = TbsCertificate {\n        version: Version::V3,\n        serial_number,\n        signature: AlgorithmIdentifier {\n            oid: rfc5912::ECDSA_WITH_SHA_384,\n            parameters: None,\n        },\n        issuer,\n        validity,\n        subject,\n        subject_public_key_info: public_key,\n        issuer_unique_id: None,\n        subject_unique_id: None,\n        extensions: Some(vec![\n            Extension {\n                extn_id: rfc5280::ID_CE_BASIC_CONSTRAINTS,\n                critical: true,\n                extn_value: OctetString::new(\n                    BasicConstraints {\n                        ca: false,\n                        path_len_constraint: None,\n                    }\n                    .to_der()?,\n                )?,\n            },\n            Extension {\n                extn_id: rfc5280::ID_CE_KEY_USAGE,\n                critical: true,\n                extn_value: OctetString::new({\n                    (KeyUsage::from(\n                        KeyUsages::DigitalSignature\n                            | KeyUsages::KeyEncipherment\n                            | KeyUsages::DataEncipherment,\n                    ))\n                    .to_der()?\n                })?,\n            },\n        ]),\n    };\n\n    let signature = {\n        // Sign the certificate\n        let rng = aws_lc_rs::rand::SystemRandom::new();\n        let tbs_cert_der = tbs_cert.to_der()?;\n        ca_key_pair.sign(&rng, &tbs_cert_der)?\n    };\n\n    let certificate = Certificate {\n        tbs_certificate: tbs_cert,\n        signature_algorithm: AlgorithmIdentifier {\n            oid: rfc5912::ECDSA_WITH_SHA_384,\n            parameters: None,\n        },\n        signature: der::asn1::BitString::from_bytes(signature.as_ref())?,\n    };\n\n    Ok(certificate)\n}\n\n/// Convert a certificate to PEM format\npub fn certificate_to_pem(certificate: &Certificate) -> Result<String, CaError> {\n    let der = certificate.to_der()?;\n    let pem_data = pem::Pem::new(\"CERTIFICATE\", der);\n    Ok(pem::encode(&pem_data))\n}\n"
  },
  {
    "path": "warpgate-common/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-common\"\nversion = \"0.22.0\"\n\n[[bin]]\nname = \"config-schema\"\npath = \"src/config_schema.rs\"\n\n[dependencies]\nanyhow.workspace = true\nclap = { version = \"4.0\", features = [\"derive\", \"std\"], default-features = false }\nargon2 = { version = \"0.5\", default-features = false }\nasync-trait = { version = \"0.1\", default-features = false }\nbytes.workspace = true\nchrono = { version = \"0.4\", default-features = false, features = [\"serde\"] }\ndata-encoding.workspace = true\ndelegate.workspace = true\nfutures.workspace = true\ngit-version = { version = \"0.3.9\", default-features = false }\ngovernor.workspace = true\nhumantime-serde = { version = \"1.1\", default-features = false }\nonce_cell = { version = \"1.17\", default-features = false }\npassword-hash.workspace = true\npoem.workspace = true\npoem-openapi.workspace = true\nrand_chacha.workspace = true\nrand_core.workspace = true\nrand.workspace = true\nrcgen.workspace = true\nreqwest.workspace = true\nreqwest-websocket.workspace = true\nrussh.workspace = true\nrustls-native-certs = { version = \"0.8\", default-features = false }\nrustls-pki-types.workspace = true\nrustls.workspace = true\nschemars.workspace = true\nsea-orm.workspace = true\nserde_json.workspace = true\nserde.workspace = true\nthiserror.workspace = true\ntokio-rustls.workspace = true\ntokio-stream.workspace = true\ntokio-tungstenite.workspace = true\ntokio.workspace = true\ntotp-rs = { version = \"5.0\", features = [\"otpauth\"], default-features = false }\ntracing-core = { version = \"0.1\", default-features = false }\ntracing.workspace = true\nurl = { version = \"2.2\", default-features = false }\nuuid.workspace = true\nwarpgate-ca = { version = \"*\", path = \"../warpgate-ca\", default-features = false }\nwarpgate-ldap = { version = \"*\", path = \"../warpgate-ldap\" }\nwarpgate-sso = { version = \"*\", path = \"../warpgate-sso\", default-features = false }\nwarpgate-tls = { version = \"*\", path = \"../warpgate-tls\", default-features = false }\nwebpki = { version = \"0.22\", default-features = false }\nx509-parser = \"0.17.0\"\n"
  },
  {
    "path": "warpgate-common/src/api.rs",
    "content": "use poem_openapi::auth::ApiKey;\nuse poem_openapi::SecurityScheme;\n\n#[derive(SecurityScheme)]\n#[oai(ty = \"api_key\", key_name = \"X-Warpgate-Token\", key_in = \"header\")]\n#[allow(dead_code)]\npub struct TokenSecurityScheme(ApiKey);\n\n#[derive(SecurityScheme)]\n#[oai(ty = \"api_key\", key_name = \"warpgate-http-session\", key_in = \"cookie\")]\n#[allow(dead_code)]\npub struct CookieSecurityScheme(ApiKey);\n\n#[derive(SecurityScheme)]\n#[allow(dead_code)]\npub enum AnySecurityScheme {\n    Token(TokenSecurityScheme),\n    Cookie(CookieSecurityScheme),\n}\n"
  },
  {
    "path": "warpgate-common/src/auth/cred.rs",
    "content": "use bytes::Bytes;\nuse poem_openapi::Enum;\nuse russh::keys::Algorithm;\nuse serde::{Deserialize, Serialize};\n\nuse crate::{Secret, UserCertificateCredential};\n\n#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash, Enum)]\npub enum CredentialKind {\n    #[serde(rename = \"password\")]\n    Password,\n    #[serde(rename = \"publickey\")]\n    PublicKey,\n    #[serde(rename = \"certificate\")]\n    Certificate,\n    #[serde(rename = \"otp\")]\n    Totp,\n    #[serde(rename = \"sso\")]\n    Sso,\n    #[serde(rename = \"web\")]\n    WebUserApproval,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum AuthCredential {\n    Otp(Secret<String>),\n    Password(Secret<String>),\n    PublicKey {\n        kind: Algorithm,\n        public_key_bytes: Bytes,\n    },\n    Certificate {\n        certificate_pem: Secret<String>,\n    },\n    Sso {\n        provider: String,\n        email: String,\n    },\n    WebUserApproval,\n}\n\nimpl AuthCredential {\n    pub fn kind(&self) -> CredentialKind {\n        match self {\n            Self::Password { .. } => CredentialKind::Password,\n            Self::PublicKey { .. } => CredentialKind::PublicKey,\n            Self::Certificate { .. } => CredentialKind::Certificate,\n            Self::Otp { .. } => CredentialKind::Totp,\n            Self::Sso { .. } => CredentialKind::Sso,\n            Self::WebUserApproval => CredentialKind::WebUserApproval,\n        }\n    }\n\n    pub fn safe_description(&self) -> String {\n        match self {\n            Self::Password { .. } => \"password\".to_string(),\n            Self::PublicKey { .. } => \"public key\".to_string(),\n            Self::Certificate { .. } => \"client certificate\".to_string(),\n            Self::Otp { .. } => \"one-time password\".to_string(),\n            Self::Sso { provider, .. } => format!(\"SSO ({provider})\"),\n            Self::WebUserApproval => \"in-browser auth\".to_string(),\n        }\n    }\n}\n\nimpl From<UserCertificateCredential> for AuthCredential {\n    fn from(cred: UserCertificateCredential) -> Self {\n        AuthCredential::Certificate {\n            certificate_pem: cred.certificate_pem,\n        }\n    }\n}\n\nimpl From<AuthCredential> for Option<UserCertificateCredential> {\n    fn from(cred: AuthCredential) -> Self {\n        match cred {\n            AuthCredential::Certificate { certificate_pem } => {\n                Some(UserCertificateCredential { certificate_pem })\n            }\n            _ => None,\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/auth/mod.rs",
    "content": "mod cred;\nmod policy;\nmod selector;\nmod state;\npub use cred::*;\npub use policy::*;\npub use selector::*;\npub use state::*;\n"
  },
  {
    "path": "warpgate-common/src/auth/policy.rs",
    "content": "use std::collections::{HashMap, HashSet};\n\nuse super::{AuthCredential, CredentialKind};\n\npub enum CredentialPolicyResponse {\n    Ok,\n    Need(HashSet<CredentialKind>),\n}\n\npub trait CredentialPolicy {\n    fn is_sufficient(\n        &self,\n        protocol: &str,\n        valid_credentials: &[AuthCredential],\n    ) -> CredentialPolicyResponse;\n}\n\npub struct AnySingleCredentialPolicy {\n    pub supported_credential_types: HashSet<CredentialKind>,\n}\n\npub struct AllCredentialsPolicy {\n    pub required_credential_types: HashSet<CredentialKind>,\n    pub supported_credential_types: HashSet<CredentialKind>,\n}\n\npub struct PerProtocolCredentialPolicy {\n    pub protocols: HashMap<&'static str, Box<dyn CredentialPolicy + Send + Sync>>,\n    pub default: Box<dyn CredentialPolicy + Send + Sync>,\n}\n\nimpl CredentialPolicy for AnySingleCredentialPolicy {\n    fn is_sufficient(\n        &self,\n        _protocol: &str,\n        valid_credentials: &[AuthCredential],\n    ) -> CredentialPolicyResponse {\n        if valid_credentials.is_empty() {\n            CredentialPolicyResponse::Need(\n                self.supported_credential_types\n                    .clone()\n                    .into_iter()\n                    .collect(),\n            )\n        } else {\n            CredentialPolicyResponse::Ok\n        }\n    }\n}\n\nimpl CredentialPolicy for AllCredentialsPolicy {\n    fn is_sufficient(\n        &self,\n        _protocol: &str,\n        valid_credentials: &[AuthCredential],\n    ) -> CredentialPolicyResponse {\n        let valid_credential_types: HashSet<CredentialKind> =\n            valid_credentials.iter().map(|x| x.kind()).collect();\n\n        if !valid_credential_types.is_empty()\n            && valid_credential_types.is_superset(&self.required_credential_types)\n        {\n            CredentialPolicyResponse::Ok\n        } else {\n            CredentialPolicyResponse::Need(\n                self.required_credential_types\n                    .difference(&valid_credential_types)\n                    .cloned()\n                    .collect(),\n            )\n        }\n    }\n}\n\nimpl CredentialPolicy for PerProtocolCredentialPolicy {\n    fn is_sufficient(\n        &self,\n        protocol: &str,\n        valid_credentials: &[AuthCredential],\n    ) -> CredentialPolicyResponse {\n        if let Some(policy) = self.protocols.get(protocol) {\n            policy.is_sufficient(protocol, valid_credentials)\n        } else {\n            self.default.is_sufficient(protocol, valid_credentials)\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/auth/selector.rs",
    "content": "use std::fmt::Debug;\n\nuse crate::consts::TICKET_SELECTOR_PREFIX;\nuse crate::Secret;\n\npub enum AuthSelector {\n    User {\n        username: String,\n        target_name: String,\n    },\n    Ticket {\n        secret: Secret<String>,\n    },\n}\n\nimpl<T: AsRef<str>> From<T> for AuthSelector {\n    fn from(selector: T) -> Self {\n        if let Some(secret) = selector.as_ref().strip_prefix(TICKET_SELECTOR_PREFIX) {\n            let secret = Secret::new(secret.into());\n            return AuthSelector::Ticket { secret };\n        }\n\n        let separator = if selector.as_ref().contains('#') {\n            '#'\n        } else {\n            ':'\n        };\n\n        let mut parts = selector.as_ref().splitn(2, separator);\n        let username = parts.next().unwrap_or(\"\").to_string();\n        let target_name = parts.next().unwrap_or(\"\").to_string();\n        AuthSelector::User {\n            username,\n            target_name,\n        }\n    }\n}\n\nimpl Debug for AuthSelector {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            AuthSelector::User {\n                username,\n                target_name,\n            } => write!(f, \"<{username} for {target_name}>\"),\n            AuthSelector::Ticket { .. } => write!(f, \"<ticket>\"),\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/auth/state.rs",
    "content": "use std::collections::HashSet;\n\nuse chrono::{DateTime, Utc};\nuse rand::Rng;\nuse tokio::sync::broadcast;\nuse tracing::{debug, info};\nuse uuid::Uuid;\n\nuse super::{AuthCredential, CredentialKind, CredentialPolicy, CredentialPolicyResponse};\nuse crate::{SessionId, User, WarpgateConfig, WarpgateError};\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum AuthResult {\n    Accepted { user_info: AuthStateUserInfo },\n    Need(HashSet<CredentialKind>),\n    Rejected,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct AuthStateUserInfo {\n    pub id: Uuid,\n    pub username: String,\n}\n\nimpl From<&User> for AuthStateUserInfo {\n    fn from(user: &User) -> Self {\n        AuthStateUserInfo {\n            id: user.id,\n            username: user.username.clone(),\n        }\n    }\n}\n\npub struct AuthState {\n    id: Uuid,\n    user_info: AuthStateUserInfo,\n    session_id: Option<Uuid>,\n    protocol: String,\n    force_rejected: bool,\n    policy: Box<dyn CredentialPolicy + Sync + Send>,\n    valid_credentials: Vec<AuthCredential>,\n    started: DateTime<Utc>,\n    identification_string: String,\n    last_result: Option<AuthResult>,\n    state_change_signal: broadcast::Sender<AuthResult>,\n}\n\nfn generate_identification_string() -> String {\n    let mut s = String::new();\n    let mut rng = rand::thread_rng();\n    for _ in 0..4 {\n        s.push_str(&format!(\"{:X}\", rng.gen_range(0..16)));\n    }\n    s\n}\n\nimpl AuthState {\n    pub fn new(\n        id: Uuid,\n        session_id: Option<SessionId>,\n        user_info: AuthStateUserInfo,\n        protocol: String,\n        policy: Box<dyn CredentialPolicy + Sync + Send>,\n        state_change_signal: broadcast::Sender<AuthResult>,\n    ) -> Self {\n        let mut this = Self {\n            id,\n            session_id,\n            user_info,\n            protocol,\n            force_rejected: false,\n            policy,\n            valid_credentials: vec![],\n            started: Utc::now(),\n            identification_string: generate_identification_string(),\n            last_result: None,\n            state_change_signal,\n        };\n        this.maybe_update_verification_state();\n        this\n    }\n\n    pub fn id(&self) -> &Uuid {\n        &self.id\n    }\n\n    pub fn session_id(&self) -> &Option<SessionId> {\n        &self.session_id\n    }\n\n    pub fn user_info(&self) -> &AuthStateUserInfo {\n        &self.user_info\n    }\n\n    pub fn protocol(&self) -> &str {\n        &self.protocol\n    }\n\n    pub fn started(&self) -> &DateTime<Utc> {\n        &self.started\n    }\n\n    pub fn identification_string(&self) -> &str {\n        &self.identification_string\n    }\n\n    pub fn add_valid_credential(&mut self, credential: AuthCredential) {\n        self.valid_credentials.push(credential);\n        self.maybe_update_verification_state();\n    }\n\n    pub fn reject(&mut self) {\n        self.force_rejected = true;\n    }\n\n    pub fn verify(&self) -> AuthResult {\n        self.current_verification_state()\n    }\n\n    fn current_verification_state(&self) -> AuthResult {\n        if self.force_rejected {\n            return AuthResult::Rejected;\n        }\n        match self\n            .policy\n            .is_sufficient(&self.protocol, &self.valid_credentials[..])\n        {\n            CredentialPolicyResponse::Ok => {\n                info!(\n                    username=%self.user_info.username,\n                    credentials=%self.valid_credentials\n                        .iter()\n                        .map(|x| x.safe_description())\n                        .collect::<Vec<_>>()\n                        .join(\", \"),\n                    \"Authenticated\",\n                );\n                AuthResult::Accepted {\n                    user_info: self.user_info.clone(),\n                }\n            }\n            CredentialPolicyResponse::Need(kinds) => AuthResult::Need(kinds),\n        }\n    }\n\n    fn maybe_update_verification_state(&mut self) -> AuthResult {\n        let new_result = self.current_verification_state();\n        if Some(new_result.clone()) != self.last_result {\n            debug!(\n                \"Verification state changed for auth state {}: {:?} -> {:?}\",\n                self.id, self.last_result, &new_result\n            );\n            let _ = self.state_change_signal.send(new_result.clone());\n        }\n        self.last_result = Some(new_result.clone());\n\n        new_result\n    }\n\n    pub fn construct_web_approval_url(\n        &self,\n        config: &WarpgateConfig,\n    ) -> Result<url::Url, WarpgateError> {\n        let mut external_url = config.construct_external_url(None, None)?;\n        external_url.set_path(\"@warpgate\");\n        external_url.set_fragment(Some(&format!(\"/login/{}\", self.id())));\n        Ok(external_url)\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/config/defaults.rs",
    "content": "use std::net::{Ipv6Addr, SocketAddr};\nuse std::time::Duration;\n\nuse crate::{ListenEndpoint, Secret};\n\npub(crate) const fn _default_true() -> bool {\n    true\n}\n\npub(crate) const fn _default_false() -> bool {\n    false\n}\n\npub(crate) const fn _default_ssh_port() -> u16 {\n    22\n}\n\npub(crate) const fn _default_mysql_port() -> u16 {\n    3306\n}\n\n#[inline]\npub(crate) fn _default_username() -> String {\n    \"root\".to_owned()\n}\n\n#[inline]\npub(crate) fn _default_empty_string() -> String {\n    \"\".to_owned()\n}\n\n#[inline]\npub(crate) fn _default_recordings_path() -> String {\n    \"./data/recordings\".to_owned()\n}\n\n#[inline]\npub(crate) fn _default_database_url() -> Secret<String> {\n    Secret::new(\"sqlite:data/db\".to_owned())\n}\n\n#[inline]\npub(crate) fn _default_http_listen() -> ListenEndpoint {\n    ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 8888))\n}\n\n#[inline]\npub(crate) fn _default_mysql_listen() -> ListenEndpoint {\n    ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 33306))\n}\n\n#[inline]\npub(crate) fn _default_postgres_listen() -> ListenEndpoint {\n    ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 55432))\n}\n\n#[inline]\npub(crate) fn _default_kubernetes_listen() -> ListenEndpoint {\n    ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 8443))\n}\n\n#[inline]\npub(crate) fn _default_retention() -> Duration {\n    Duration::from_secs(60 * 60 * 24 * 7)\n}\n\n#[inline]\npub(crate) fn _default_session_max_age() -> Duration {\n    Duration::from_secs(60 * 30)\n}\n\n#[inline]\npub(crate) fn _default_cookie_max_age() -> Duration {\n    Duration::from_secs(60 * 60 * 24)\n}\n\n#[inline]\npub(crate) fn _default_empty_vec<T>() -> Vec<T> {\n    vec![]\n}\n\npub(crate) fn _default_ssh_listen() -> ListenEndpoint {\n    ListenEndpoint::from(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 2222))\n}\n\npub(crate) fn _default_ssh_keys_path() -> String {\n    \"./data/keys\".to_owned()\n}\n\npub(crate) fn _default_ssh_inactivity_timeout() -> Duration {\n    Duration::from_secs(60 * 5)\n}\n\npub(crate) fn _default_postgres_idle_timeout_str() -> Option<String> {\n    Some(\"10m\".to_string())\n}\n"
  },
  {
    "path": "warpgate-common/src/config/mod.rs",
    "content": "mod defaults;\nmod target;\n\nuse std::ops::Deref;\nuse std::path::PathBuf;\nuse std::time::Duration;\n\nuse defaults::*;\nuse poem::http::uri;\nuse poem_openapi::{Object, Union};\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\npub use target::*;\nuse tracing::warn;\nuse uri::Scheme;\nuse url::Url;\nuse uuid::Uuid;\nuse warpgate_sso::SsoProviderConfig;\nuse warpgate_tls::IntoTlsCertificateRelativePaths;\n\nuse crate::auth::CredentialKind;\nuse crate::helpers::hash::hash_password;\nuse crate::helpers::otp::OtpSecretKey;\nuse crate::{ListenEndpoint, Secret, WarpgateError};\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Union)]\n#[serde(tag = \"type\")]\n#[oai(discriminator_name = \"kind\", one_of)]\npub enum UserAuthCredential {\n    #[serde(rename = \"password\")]\n    Password(UserPasswordCredential),\n    #[serde(rename = \"publickey\")]\n    PublicKey(UserPublicKeyCredential),\n    #[serde(rename = \"certificate\")]\n    Certificate(UserCertificateCredential),\n    #[serde(rename = \"otp\")]\n    Totp(UserTotpCredential),\n    #[serde(rename = \"sso\")]\n    Sso(UserSsoCredential),\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)]\npub struct UserPasswordCredential {\n    pub hash: Secret<String>,\n}\n\nimpl UserPasswordCredential {\n    pub fn from_password(password: &Secret<String>) -> Self {\n        Self {\n            hash: Secret::new(hash_password(password.expose_secret())),\n        }\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)]\npub struct UserPublicKeyCredential {\n    pub key: Secret<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)]\npub struct UserCertificateCredential {\n    pub certificate_pem: Secret<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)]\npub struct UserTotpCredential {\n    #[serde(with = \"crate::helpers::serde_base64_secret\")]\n    pub key: OtpSecretKey,\n}\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)]\npub struct UserSsoCredential {\n    pub provider: Option<String>,\n    pub email: String,\n}\n\nimpl UserAuthCredential {\n    pub fn kind(&self) -> CredentialKind {\n        match self {\n            Self::Password(_) => CredentialKind::Password,\n            Self::PublicKey(_) => CredentialKind::PublicKey,\n            Self::Certificate(_) => CredentialKind::Certificate,\n            Self::Totp(_) => CredentialKind::Totp,\n            Self::Sso(_) => CredentialKind::Sso,\n        }\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object, Default)]\npub struct UserRequireCredentialsPolicy {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub http: Option<Vec<CredentialKind>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub kubernetes: Option<Vec<CredentialKind>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub ssh: Option<Vec<CredentialKind>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub mysql: Option<Vec<CredentialKind>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub postgres: Option<Vec<CredentialKind>>,\n}\n\nimpl UserRequireCredentialsPolicy {\n    #[must_use]\n    pub fn upgrade_to_otp(&self, with_existing_credentials: &[UserAuthCredential]) -> Self {\n        let mut copy = self.clone();\n\n        if let Some(policy) = &mut copy.http {\n            policy.push(CredentialKind::Totp);\n        } else {\n            // Upgrade to OTP only if there is a password credential\n            let mut kinds = vec![];\n            if with_existing_credentials\n                .iter()\n                .any(|c| c.kind() == CredentialKind::Password)\n            {\n                kinds.push(CredentialKind::Password);\n            }\n            if !kinds.is_empty() {\n                kinds.push(CredentialKind::Totp);\n                copy.http = Some(kinds);\n            }\n        }\n\n        if let Some(policy) = &mut copy.ssh {\n            policy.push(CredentialKind::Totp);\n        } else {\n            // Upgrade to OTP only if there is a password or public key credential\n            let mut kinds = vec![];\n            if with_existing_credentials.iter().any(|c| {\n                c.kind() == CredentialKind::Password || c.kind() == CredentialKind::PublicKey\n            }) {\n                kinds.push(CredentialKind::Password);\n            }\n            if !kinds.is_empty() {\n                kinds.push(CredentialKind::Totp);\n                copy.ssh = Some(kinds);\n            }\n        }\n        copy\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object)]\npub struct User {\n    #[serde(default)]\n    pub id: Uuid,\n    pub username: String,\n    pub description: String,\n    #[serde(skip_serializing_if = \"Option::is_none\", rename = \"require\")]\n    pub credential_policy: Option<UserRequireCredentialsPolicy>,\n    pub rate_limit_bytes_per_second: Option<i64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub ldap_server_id: Option<Uuid>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object)]\npub struct UserDetails {\n    pub inner: User,\n    pub credentials: Vec<UserAuthCredential>,\n    pub roles: Vec<String>,\n}\n\nimpl Deref for UserDetails {\n    type Target = User;\n\n    fn deref(&self) -> &Self::Target {\n        &self.inner\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, Object)]\npub struct Role {\n    #[serde(default)]\n    pub id: Uuid,\n    pub name: String,\n    pub description: String,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)]\npub struct AdminRole {\n    #[serde(default)]\n    pub id: Uuid,\n    pub name: String,\n    pub description: String,\n\n    pub targets_create: bool,\n    pub targets_edit: bool,\n    pub targets_delete: bool,\n\n    pub users_create: bool,\n    pub users_edit: bool,\n    pub users_delete: bool,\n\n    pub access_roles_create: bool,\n    pub access_roles_edit: bool,\n    pub access_roles_delete: bool,\n    pub access_roles_assign: bool,\n\n    pub sessions_view: bool,\n    pub sessions_terminate: bool,\n\n    pub recordings_view: bool,\n\n    pub tickets_create: bool,\n    pub tickets_delete: bool,\n\n    pub config_edit: bool,\n\n    pub admin_roles_manage: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]\n#[serde(rename_all = \"snake_case\")]\npub enum AdminPermission {\n    TargetsCreate,\n    TargetsEdit,\n    TargetsDelete,\n    UsersCreate,\n    UsersEdit,\n    UsersDelete,\n    AccessRolesCreate,\n    AccessRolesEdit,\n    AccessRolesDelete,\n    AccessRolesAssign,\n    SessionsView,\n    SessionsTerminate,\n    RecordingsView,\n    TicketsCreate,\n    TicketsDelete,\n    ConfigEdit,\n    AdminRolesManage,\n}\n\nimpl AdminRole {\n    pub fn has_permission(&self, perm: AdminPermission) -> bool {\n        match perm {\n            AdminPermission::TargetsCreate => self.targets_create,\n            AdminPermission::TargetsEdit => self.targets_edit,\n            AdminPermission::TargetsDelete => self.targets_delete,\n            AdminPermission::UsersCreate => self.users_create,\n            AdminPermission::UsersEdit => self.users_edit,\n            AdminPermission::UsersDelete => self.users_delete,\n            AdminPermission::AccessRolesCreate => self.access_roles_create,\n            AdminPermission::AccessRolesEdit => self.access_roles_edit,\n            AdminPermission::AccessRolesDelete => self.access_roles_delete,\n            AdminPermission::AccessRolesAssign => self.access_roles_assign,\n            AdminPermission::SessionsView => self.sessions_view,\n            AdminPermission::SessionsTerminate => self.sessions_terminate,\n            AdminPermission::RecordingsView => self.recordings_view,\n            AdminPermission::TicketsCreate => self.tickets_create,\n            AdminPermission::TicketsDelete => self.tickets_delete,\n            AdminPermission::ConfigEdit => self.config_edit,\n            AdminPermission::AdminRolesManage => self.admin_roles_manage,\n        }\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq, Copy, JsonSchema)]\npub enum SshHostKeyVerificationMode {\n    #[serde(rename = \"prompt\")]\n    #[default]\n    Prompt,\n    #[serde(rename = \"auto_accept\")]\n    AutoAccept,\n    #[serde(rename = \"auto_reject\")]\n    AutoReject,\n}\n\n#[derive(\n    Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq, JsonSchema, clap::ValueEnum,\n)]\n#[serde(rename_all = \"lowercase\")]\npub enum LogFormat {\n    #[default]\n    Text,\n    Json,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]\npub struct SshConfig {\n    #[serde(default = \"_default_false\")]\n    pub enable: bool,\n\n    #[serde(default = \"_default_ssh_listen\")]\n    pub listen: ListenEndpoint,\n\n    #[serde(default)]\n    pub external_port: Option<u16>,\n\n    #[serde(default = \"_default_ssh_keys_path\")]\n    pub keys: String,\n\n    #[serde(default)]\n    pub host_key_verification: SshHostKeyVerificationMode,\n\n    #[serde(default = \"_default_ssh_inactivity_timeout\", with = \"humantime_serde\")]\n    #[schemars(with = \"String\")]\n    pub inactivity_timeout: Duration,\n\n    #[serde(default)]\n    pub keepalive_interval: Option<Duration>,\n}\n\nimpl Default for SshConfig {\n    fn default() -> Self {\n        SshConfig {\n            enable: false,\n            listen: _default_ssh_listen(),\n            keys: _default_ssh_keys_path(),\n            host_key_verification: Default::default(),\n            external_port: None,\n            inactivity_timeout: _default_ssh_inactivity_timeout(),\n            keepalive_interval: None,\n        }\n    }\n}\n\nimpl SshConfig {\n    pub fn external_port(&self) -> u16 {\n        self.external_port.unwrap_or(self.listen.port())\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]\npub struct SniCertificateConfig {\n    pub certificate: String,\n    pub key: String,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]\npub struct HttpConfig {\n    #[serde(default = \"_default_http_listen\")]\n    pub listen: ListenEndpoint,\n\n    #[serde(default)]\n    pub external_port: Option<u16>,\n\n    #[serde(default)]\n    pub certificate: String,\n\n    #[serde(default)]\n    pub key: String,\n\n    #[serde(default)]\n    pub trust_x_forwarded_headers: bool,\n\n    #[serde(default = \"_default_session_max_age\", with = \"humantime_serde\")]\n    #[schemars(with = \"String\")]\n    pub session_max_age: Duration,\n\n    #[serde(default = \"_default_cookie_max_age\", with = \"humantime_serde\")]\n    #[schemars(with = \"String\")]\n    pub cookie_max_age: Duration,\n\n    #[serde(default)]\n    pub sni_certificates: Vec<SniCertificateConfig>,\n}\n\nimpl Default for HttpConfig {\n    fn default() -> Self {\n        HttpConfig {\n            listen: _default_http_listen(),\n            external_port: None,\n            certificate: \"\".to_owned(),\n            key: \"\".to_owned(),\n            trust_x_forwarded_headers: false,\n            session_max_age: _default_session_max_age(),\n            cookie_max_age: _default_cookie_max_age(),\n            sni_certificates: vec![],\n        }\n    }\n}\n\nimpl HttpConfig {\n    pub fn external_port(&self) -> u16 {\n        self.external_port.unwrap_or(self.listen.port())\n    }\n}\n\nimpl IntoTlsCertificateRelativePaths for HttpConfig {\n    fn certificate_path(&self) -> PathBuf {\n        self.certificate.as_str().into()\n    }\n\n    fn key_path(&self) -> PathBuf {\n        self.key.as_str().into()\n    }\n}\n\nimpl IntoTlsCertificateRelativePaths for SniCertificateConfig {\n    fn certificate_path(&self) -> PathBuf {\n        self.certificate.as_str().into()\n    }\n\n    fn key_path(&self) -> PathBuf {\n        self.key.as_str().into()\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]\npub struct MySqlConfig {\n    #[serde(default = \"_default_false\")]\n    pub enable: bool,\n\n    #[serde(default = \"_default_mysql_listen\")]\n    pub listen: ListenEndpoint,\n\n    #[serde(default)]\n    pub external_port: Option<u16>,\n\n    #[serde(default)]\n    pub certificate: String,\n\n    #[serde(default)]\n    pub key: String,\n}\n\nimpl Default for MySqlConfig {\n    fn default() -> Self {\n        MySqlConfig {\n            enable: false,\n            listen: _default_mysql_listen(),\n            external_port: None,\n            certificate: \"\".to_owned(),\n            key: \"\".to_owned(),\n        }\n    }\n}\n\nimpl MySqlConfig {\n    pub fn external_port(&self) -> u16 {\n        self.external_port.unwrap_or(self.listen.port())\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]\npub struct KubernetesConfig {\n    #[serde(default = \"_default_false\")]\n    pub enable: bool,\n\n    #[serde(default = \"_default_kubernetes_listen\")]\n    pub listen: ListenEndpoint,\n\n    #[serde(default)]\n    pub external_port: Option<u16>,\n\n    #[serde(default)]\n    pub certificate: String,\n\n    #[serde(default)]\n    pub key: String,\n\n    #[serde(default = \"_default_session_max_age\", with = \"humantime_serde\")]\n    #[schemars(with = \"String\")]\n    pub session_max_age: Duration,\n}\n\nimpl Default for KubernetesConfig {\n    fn default() -> Self {\n        KubernetesConfig {\n            enable: false,\n            listen: _default_kubernetes_listen(),\n            external_port: None,\n            certificate: \"\".to_owned(),\n            key: \"\".to_owned(),\n            session_max_age: _default_session_max_age(),\n        }\n    }\n}\n\nimpl KubernetesConfig {\n    pub fn external_port(&self) -> u16 {\n        self.external_port.unwrap_or(self.listen.port())\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]\npub struct PostgresConfig {\n    #[serde(default = \"_default_false\")]\n    pub enable: bool,\n\n    #[serde(default = \"_default_postgres_listen\")]\n    pub listen: ListenEndpoint,\n\n    #[serde(default)]\n    pub external_port: Option<u16>,\n\n    #[serde(default)]\n    pub certificate: String,\n\n    #[serde(default)]\n    pub key: String,\n}\n\nimpl Default for PostgresConfig {\n    fn default() -> Self {\n        PostgresConfig {\n            enable: false,\n            listen: _default_postgres_listen(),\n            external_port: None,\n            certificate: \"\".to_owned(),\n            key: \"\".to_owned(),\n        }\n    }\n}\n\nimpl PostgresConfig {\n    pub fn external_port(&self) -> u16 {\n        self.external_port.unwrap_or(self.listen.port())\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]\npub struct RecordingsConfig {\n    #[serde(default = \"_default_false\")]\n    pub enable: bool,\n\n    #[serde(default = \"_default_recordings_path\")]\n    pub path: String,\n}\n\nimpl Default for RecordingsConfig {\n    fn default() -> Self {\n        Self {\n            enable: false,\n            path: _default_recordings_path(),\n        }\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]\npub struct LogConfig {\n    #[serde(default = \"_default_retention\", with = \"humantime_serde\")]\n    #[schemars(with = \"String\")]\n    pub retention: Duration,\n\n    #[serde(default)]\n    pub send_to: Option<String>,\n\n    #[serde(default)]\n    pub format: LogFormat,\n}\n\nimpl Default for LogConfig {\n    fn default() -> Self {\n        Self {\n            retention: _default_retention(),\n            send_to: None,\n            format: LogFormat::default(),\n        }\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]\npub struct WarpgateConfigStore {\n    #[serde(default)]\n    pub sso_providers: Vec<SsoProviderConfig>,\n\n    #[serde(default)]\n    pub recordings: RecordingsConfig,\n\n    #[serde(default)]\n    pub external_host: Option<String>,\n\n    #[serde(default = \"_default_database_url\")]\n    #[schemars(with = \"String\")]\n    pub database_url: Secret<String>,\n\n    #[serde(default)]\n    pub ssh: SshConfig,\n\n    #[serde(default)]\n    pub http: HttpConfig,\n\n    #[serde(default)]\n    pub kubernetes: KubernetesConfig,\n\n    #[serde(default)]\n    pub mysql: MySqlConfig,\n\n    #[serde(default)]\n    pub postgres: PostgresConfig,\n\n    #[serde(default)]\n    pub log: LogConfig,\n}\n\nimpl Default for WarpgateConfigStore {\n    fn default() -> Self {\n        Self {\n            sso_providers: vec![],\n            recordings: <_>::default(),\n            external_host: None,\n            database_url: _default_database_url(),\n            ssh: <_>::default(),\n            http: <_>::default(),\n            kubernetes: <_>::default(),\n            mysql: <_>::default(),\n            postgres: <_>::default(),\n            log: <_>::default(),\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct WarpgateConfig {\n    pub store: WarpgateConfigStore,\n}\n\nimpl WarpgateConfig {\n    pub fn external_host_from_config(&self) -> Option<(Scheme, String, Option<u16>)> {\n        if let Some(external_host) = self.store.external_host.as_ref() {\n            #[allow(clippy::unwrap_used)]\n            let external_host = external_host.split(\":\").next().unwrap();\n\n            Some((\n                Scheme::HTTPS,\n                external_host.to_owned(),\n                self.store\n                    .http\n                    .external_port\n                    .or(Some(self.store.http.listen.port())),\n            ))\n        } else {\n            None\n        }\n    }\n\n    /// Extract external host:port from request headers\n    pub fn external_host_from_request(\n        &self,\n        request: &poem::Request,\n    ) -> Option<(Scheme, String, Option<u16>)> {\n        let (mut scheme, mut host, mut port) = (Scheme::HTTPS, None, None);\n        let trust_forwarded_headers = self.store.http.trust_x_forwarded_headers;\n\n        // Try the Host header first\n        scheme = request.uri().scheme().cloned().unwrap_or(scheme);\n\n        let original_url = request.original_uri();\n        if let Some(original_host) = original_url.host() {\n            host = Some(original_host.to_string());\n            port = original_url.port().map(|x| x.as_u16());\n        }\n\n        // But prefer X-Forwarded-* headers if enabled\n        if trust_forwarded_headers {\n            scheme = request\n                .header(\"x-forwarded-proto\")\n                .and_then(|x| Scheme::try_from(x).ok())\n                .unwrap_or(scheme);\n\n            if let Some(xfh) = request.header(\"x-forwarded-host\") {\n                // XFH can contain both host and port\n                let parts = xfh.split(':').collect::<Vec<_>>();\n                host = parts.first().map(|x| x.to_string()).or(host);\n                port = parts.get(1).and_then(|x| x.parse::<u16>().ok());\n            }\n\n            port = request\n                .header(\"x-forwarded-port\")\n                .and_then(|x| x.parse::<u16>().ok())\n                .or(port);\n        }\n\n        host.map(|host| (scheme, host, port))\n    }\n\n    pub fn construct_external_url(\n        &self,\n        for_request: Option<&poem::Request>,\n        domain_whitelist: Option<&[String]>,\n    ) -> Result<Url, WarpgateError> {\n        let Some((scheme, host, port)) = for_request\n            .and_then(|r| self.external_host_from_request(r))\n            .or(self.external_host_from_config())\n        else {\n            return Err(WarpgateError::ExternalHostUnknown);\n        };\n\n        if let Some(list) = domain_whitelist {\n            if !list.contains(&host) {\n                return Err(WarpgateError::ExternalHostNotWhitelisted(\n                    host.clone(),\n                    list.iter().map(|x| x.to_string()).collect(),\n                ));\n            }\n        }\n\n        let mut url = format!(\"{scheme}://{host}\");\n        if let Some(port) = port {\n            // can't `match` `Scheme`\n            if scheme == Scheme::HTTP && port != 80 || scheme == Scheme::HTTPS && port != 443 {\n                url = format!(\"{url}:{port}\");\n            }\n        };\n        Url::parse(&url).map_err(WarpgateError::UrlParse)\n    }\n\n    pub fn validate(&self) {\n        if let Some(ref ext) = self.store.external_host {\n            if ext.contains(':') {\n                warn!(\"Looks like your `external_host` config option contains a port - it will be ignored.\");\n                warn!(\"Set the external port via the `http.external_port`, `ssh.external_port` or `mysql.external_port` options.\");\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/config/target.rs",
    "content": "use std::collections::HashMap;\n\nuse poem_openapi::{Object, Union};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\nuse warpgate_tls::TlsMode;\n\nuse super::defaults::*;\nuse crate::Secret;\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)]\npub struct KubernetesTargetCertificateAuth {\n    pub certificate: Secret<String>,\n    pub private_key: Secret<String>,\n}\n\nimpl Default for KubernetesTargetCertificateAuth {\n    fn default() -> Self {\n        Self {\n            certificate: Secret::new(String::new()),\n            private_key: Secret::new(String::new()),\n        }\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object)]\npub struct TargetSSHOptions {\n    pub host: String,\n    #[serde(default = \"_default_ssh_port\")]\n    pub port: u16,\n    #[serde(default = \"_default_username\")]\n    pub username: String,\n    #[serde(default)]\n    pub allow_insecure_algos: Option<bool>,\n    #[serde(default)]\n    pub auth: SSHTargetAuth,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Union)]\n#[serde(untagged)]\n#[oai(discriminator_name = \"kind\", one_of)]\npub enum SSHTargetAuth {\n    #[serde(rename = \"password\")]\n    Password(SshTargetPasswordAuth),\n    #[serde(rename = \"publickey\")]\n    PublicKey(SshTargetPublicKeyAuth),\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)]\npub struct SshTargetPasswordAuth {\n    pub password: Secret<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object, Default)]\npub struct SshTargetPublicKeyAuth {}\n\nimpl Default for SSHTargetAuth {\n    fn default() -> Self {\n        SSHTargetAuth::PublicKey(SshTargetPublicKeyAuth::default())\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object)]\npub struct TargetHTTPOptions {\n    #[serde(default = \"_default_empty_string\")]\n    pub url: String,\n\n    #[serde(default)]\n    pub tls: Tls,\n\n    #[serde(default)]\n    pub headers: Option<HashMap<String, String>>,\n\n    #[serde(default)]\n    pub external_host: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object)]\npub struct Tls {\n    #[serde(default)]\n    pub mode: TlsMode,\n\n    #[serde(default = \"_default_true\")]\n    pub verify: bool,\n}\n\n#[allow(clippy::derivable_impls)]\nimpl Default for Tls {\n    fn default() -> Self {\n        Self {\n            mode: TlsMode::default(),\n            verify: false,\n        }\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object)]\npub struct TargetMySqlOptions {\n    #[serde(default = \"_default_empty_string\")]\n    pub host: String,\n\n    #[serde(default = \"_default_mysql_port\")]\n    pub port: u16,\n\n    #[serde(default = \"_default_username\")]\n    pub username: String,\n\n    #[serde(default)]\n    pub password: Option<String>,\n\n    #[serde(default)]\n    pub tls: Tls,\n\n    #[serde(default)]\n    pub default_database_name: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object)]\npub struct TargetPostgresOptions {\n    #[serde(default = \"_default_empty_string\")]\n    pub host: String,\n\n    #[serde(default = \"_default_mysql_port\")]\n    pub port: u16,\n\n    #[serde(default = \"_default_username\")]\n    pub username: String,\n\n    #[serde(default)]\n    pub password: Option<String>,\n\n    #[serde(default)]\n    pub tls: Tls,\n\n    #[serde(default = \"_default_postgres_idle_timeout_str\")]\n    pub idle_timeout: Option<String>,\n\n    #[serde(default)]\n    pub default_database_name: Option<String>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object)]\npub struct TargetKubernetesOptions {\n    #[serde(default = \"_default_empty_string\")]\n    pub cluster_url: String,\n\n    #[serde(default)]\n    pub tls: Tls,\n\n    #[serde(default)]\n    pub auth: KubernetesTargetAuth,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Union)]\n#[serde(untagged)]\n#[oai(discriminator_name = \"kind\", one_of)]\npub enum KubernetesTargetAuth {\n    #[serde(rename = \"token\")]\n    Token(KubernetesTargetTokenAuth),\n    #[serde(rename = \"certificate\")]\n    Certificate(KubernetesTargetCertificateAuth),\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Object)]\npub struct KubernetesTargetTokenAuth {\n    pub token: Secret<String>,\n}\n\nimpl Default for KubernetesTargetAuth {\n    fn default() -> Self {\n        KubernetesTargetAuth::Certificate(KubernetesTargetCertificateAuth::default())\n    }\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Object)]\npub struct Target {\n    #[serde(default)]\n    pub id: Uuid,\n    pub name: String,\n    pub description: String,\n    #[serde(default = \"_default_empty_vec\")]\n    pub allow_roles: Vec<String>,\n    #[serde(flatten)]\n    pub options: TargetOptions,\n    pub rate_limit_bytes_per_second: Option<u32>,\n    pub group_id: Option<Uuid>,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone, Union)]\n#[oai(discriminator_name = \"kind\", one_of)]\npub enum TargetOptions {\n    #[serde(rename = \"ssh\")]\n    Ssh(TargetSSHOptions),\n    #[serde(rename = \"http\")]\n    Http(TargetHTTPOptions),\n    #[serde(rename = \"kubernetes\")]\n    Kubernetes(TargetKubernetesOptions),\n    #[serde(rename = \"mysql\")]\n    MySql(TargetMySqlOptions),\n    #[serde(rename = \"postgres\")]\n    Postgres(TargetPostgresOptions),\n}\n"
  },
  {
    "path": "warpgate-common/src/config_schema.rs",
    "content": "use schemars::schema_for;\n\n#[allow(clippy::unwrap_used)]\nfn main() {\n    let schema = schema_for!(warpgate_common::WarpgateConfigStore);\n    println!(\"{}\", serde_json::to_string_pretty(&schema).unwrap());\n}\n"
  },
  {
    "path": "warpgate-common/src/consts.rs",
    "content": "pub const TICKET_SELECTOR_PREFIX: &str = \"ticket-\";\n"
  },
  {
    "path": "warpgate-common/src/error.rs",
    "content": "use std::error::Error;\n\nuse poem::error::ResponseError;\nuse poem_openapi::ApiResponse;\nuse uuid::Uuid;\nuse warpgate_ca::CaError;\nuse warpgate_sso::SsoError;\nuse warpgate_tls::RustlsSetupError;\n\nuse crate::AdminPermission;\n\n#[derive(thiserror::Error, Debug)]\npub enum WarpgateError {\n    #[error(\"database error: {0}\")]\n    DatabaseError(#[from] sea_orm::DbErr),\n    #[error(\"ticket not found: {0}\")]\n    InvalidTicket(Uuid),\n    #[error(\"invalid credential type\")]\n    InvalidCredentialType,\n    #[error(transparent)]\n    Other(Box<dyn Error + Send + Sync>),\n    #[error(\"user {0} not found\")]\n    UserNotFound(String),\n    #[error(\"role {0} not found\")]\n    RoleNotFound(String),\n    #[error(\"failed to parse URL: {0}\")]\n    UrlParse(#[from] url::ParseError),\n    #[error(\"deserialization failed: {0}\")]\n    DeserializeJson(#[from] serde_json::Error),\n    #[error(\"no valid Host header found and `external_host` config option is not set\")]\n    ExternalHostUnknown,\n    #[error(\"current hostname ({0}) is not on the whitelist ({1:?})\")]\n    ExternalHostNotWhitelisted(String, Vec<String>),\n    #[error(\"URL contains no host\")]\n    NoHostInUrl,\n    #[error(\"Inconsistent state error\")]\n    InconsistentState,\n    #[error(transparent)]\n    Anyhow(#[from] anyhow::Error),\n    #[error(transparent)]\n    Sso(#[from] SsoError),\n    #[error(transparent)]\n    Ca(#[from] CaError),\n    #[error(transparent)]\n    Ldap(#[from] warpgate_ldap::LdapError),\n    #[error(transparent)]\n    RusshKeys(#[from] russh::keys::Error),\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(transparent)]\n    RateLimiterInsufficientCapacity(#[from] governor::InsufficientCapacity),\n    #[error(\"Invalid rate limiter quota: {0}\")]\n    RateLimiterInvalidQuota(u32),\n    #[error(\"Session end\")]\n    SessionEnd,\n    #[error(\"rcgen: {0}\")]\n    RcGen(#[from] rcgen::Error),\n    #[error(\"rustls setup: {0}\")]\n    TlsSetup(#[from] RustlsSetupError),\n    #[error(\"reqwest: {0}\")]\n    Reqwest(#[from] reqwest::Error),\n    #[error(\"admin role required\")]\n    NoAdminAccess,\n    #[error(\"admin permission required: {0:?}\")]\n    NoAdminPermission(AdminPermission),\n}\n\nimpl ResponseError for WarpgateError {\n    fn status(&self) -> poem::http::StatusCode {\n        match self {\n            WarpgateError::InvalidTicket(_)\n            | WarpgateError::UserNotFound(_)\n            | WarpgateError::RoleNotFound(_) => poem::http::StatusCode::UNAUTHORIZED,\n            WarpgateError::NoAdminAccess | WarpgateError::NoAdminPermission(_) => {\n                poem::http::StatusCode::FORBIDDEN\n            }\n            _ => poem::http::StatusCode::INTERNAL_SERVER_ERROR,\n        }\n    }\n}\n\nimpl WarpgateError {\n    pub fn other<E: Error + Send + Sync + 'static>(err: E) -> Self {\n        Self::Other(Box::new(err))\n    }\n}\n\nimpl ApiResponse for WarpgateError {\n    fn meta() -> poem_openapi::registry::MetaResponses {\n        poem::error::Error::meta()\n    }\n\n    fn register(registry: &mut poem_openapi::registry::Registry) {\n        poem::error::Error::register(registry)\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/eventhub.rs",
    "content": "use std::sync::Arc;\n\nuse tokio::sync::mpsc::error::SendError;\nuse tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};\n\nuse crate::helpers::locks::{Mutex, MutexGuard};\n\npub struct EventSender<E> {\n    subscriptions: SubscriptionStore<E>,\n}\n\nimpl<E> Clone for EventSender<E> {\n    fn clone(&self) -> Self {\n        EventSender {\n            subscriptions: self.subscriptions.clone(),\n        }\n    }\n}\n\nimpl<E> EventSender<E> {\n    async fn cleanup_subscriptions(&self) -> MutexGuard<'_, SubscriptionStoreInner<E>> {\n        let mut subscriptions = self.subscriptions.lock().await;\n        subscriptions.retain(|(_, ref s)| !s.is_closed());\n        subscriptions\n    }\n}\n\nimpl<'h, E: Clone + 'h> EventSender<E> {\n    pub async fn send_all(&'h self, event: E) -> Result<(), SendError<E>> {\n        let mut subscriptions = self.cleanup_subscriptions().await;\n\n        for (ref f, ref mut s) in subscriptions.iter_mut().rev() {\n            if f(&event) {\n                let _ = s.send(event.clone());\n            }\n        }\n        if subscriptions.is_empty() {\n            Err(SendError(event))\n        } else {\n            Ok(())\n        }\n    }\n}\n\nimpl<'h, E: 'h> EventSender<E> {\n    pub async fn send_once(&'h self, event: E) -> Result<(), SendError<E>> {\n        let mut subscriptions = self.cleanup_subscriptions().await;\n\n        for (ref f, ref mut s) in subscriptions.iter_mut().rev() {\n            if f(&event) {\n                return s.send(event);\n            }\n        }\n\n        Err(SendError(event))\n    }\n}\n\npub struct EventSubscription<E>(UnboundedReceiver<E>);\n\nimpl<E> EventSubscription<E> {\n    pub async fn recv(&mut self) -> Option<E> {\n        self.0.recv().await\n    }\n}\n\ntype SubscriptionStoreInner<E> = Vec<(Box<dyn Fn(&E) -> bool + Send>, UnboundedSender<E>)>;\ntype SubscriptionStore<E> = Arc<Mutex<SubscriptionStoreInner<E>>>;\n\npub struct EventHub<E: Send> {\n    subscriptions: SubscriptionStore<E>,\n}\n\nimpl<E: Send> EventHub<E> {\n    pub fn setup() -> (Self, EventSender<E>) {\n        let subscriptions = Arc::new(Mutex::new(vec![]));\n        (\n            Self {\n                subscriptions: subscriptions.clone(),\n            },\n            EventSender { subscriptions },\n        )\n    }\n\n    pub async fn subscribe<F: Fn(&E) -> bool + Send + 'static>(\n        &'_ self,\n        filter: F,\n    ) -> EventSubscription<E> {\n        let (sender, receiver) = unbounded_channel();\n        let mut subscriptions = self.subscriptions.lock().await;\n        subscriptions.push((Box::new(filter), sender));\n        EventSubscription(receiver)\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/helpers/fs.rs",
    "content": "use std::os::unix::prelude::PermissionsExt;\nuse std::path::Path;\n\nuse tracing::*;\n\nfn maybe_apply_permissions<P: AsRef<Path>>(\n    path: P,\n    permissions: std::fs::Permissions,\n) -> std::io::Result<()> {\n    let current = std::fs::metadata(&path)?.permissions();\n    if (current.mode() & 0o777) != permissions.mode() {\n        std::fs::set_permissions(path, permissions)?;\n    }\n    Ok(())\n}\n\nfn warn_failure(e: &std::io::Error) {\n    error!(\"Warning: failed to tighten file permissions: {}\", e);\n    error!(\"If you are managing file permissions externally and do not need Warpgate to change them, you can pass --skip-securing-files\")\n}\n\npub fn secure_directory<P: AsRef<Path>>(path: P) -> std::io::Result<()> {\n    maybe_apply_permissions(path.as_ref(), std::fs::Permissions::from_mode(0o700))\n        .inspect_err(warn_failure)\n}\n\npub fn secure_file<P: AsRef<Path>>(path: P) -> std::io::Result<()> {\n    maybe_apply_permissions(path.as_ref(), std::fs::Permissions::from_mode(0o600))\n        .inspect_err(warn_failure)\n}\n"
  },
  {
    "path": "warpgate-common/src/helpers/hash.rs",
    "content": "use anyhow::Result;\nuse argon2::password_hash::rand_core::OsRng;\nuse argon2::password_hash::{Error, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};\nuse argon2::Argon2;\nuse data_encoding::HEXLOWER;\nuse rand::Rng;\n\nuse crate::Secret;\n\npub fn hash_password(password: &str) -> String {\n    let salt = SaltString::generate(&mut OsRng);\n    let argon2 = Argon2::default();\n    // Only panics for invalid hash parameters\n    #[allow(clippy::unwrap_used)]\n    argon2\n        .hash_password(password.as_bytes(), &salt)\n        .unwrap()\n        .to_string()\n}\n\npub fn parse_hash(hash: &str) -> Result<PasswordHash<'_>, Error> {\n    PasswordHash::new(hash)\n}\n\npub fn verify_password_hash(password: &str, hash: &str) -> Result<bool> {\n    let parsed_hash = parse_hash(hash).map_err(|e| anyhow::anyhow!(e))?;\n    match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {\n        Ok(()) => Ok(true),\n        Err(Error::Password) => Ok(false),\n        Err(e) => Err(anyhow::anyhow!(e)),\n    }\n}\n\npub fn generate_ticket_secret() -> Secret<String> {\n    let mut bytes = [0; 32];\n    rand::thread_rng().fill(&mut bytes[..]);\n    Secret::new(HEXLOWER.encode(&bytes))\n}\n"
  },
  {
    "path": "warpgate-common/src/helpers/locks.rs",
    "content": "#[cfg(debug_assertions)]\nmod deadlock_detecting_mutex {\n    use std::any::type_name;\n    use std::collections::HashMap;\n    use std::ops::{Deref, DerefMut};\n    use std::sync::atomic::{AtomicBool, Ordering};\n\n    use once_cell::sync::Lazy;\n    use tokio::task::Id;\n\n    static LOCK_IDENTITIES: Lazy<std::sync::Mutex<HashMap<Option<Id>, Vec<String>>>> =\n        Lazy::new(|| std::sync::Mutex::new(HashMap::new()));\n\n    fn log_state() {\n        eprintln!(\"Tokio task: {:?}\", tokio::task::try_id());\n        #[allow(clippy::unwrap_used)]\n        let ids = LOCK_IDENTITIES.lock().unwrap();\n        let identities = ids.get(&tokio::task::try_id()).cloned().unwrap_or_default();\n        if !identities.is_empty() {\n            eprintln!(\"* Held locks: {:?}\", identities);\n        }\n    }\n\n    pub struct MutexGuard<'a, T> {\n        inner: tokio::sync::MutexGuard<'a, T>,\n        poisoned: &'a AtomicBool,\n    }\n\n    impl<'a, T> MutexGuard<'a, T> {\n        pub fn new(inner: tokio::sync::MutexGuard<'a, T>, poisoned: &'a AtomicBool) -> Self {\n            let this = Self { inner, poisoned };\n            #[allow(clippy::unwrap_used)]\n            let mut ids = LOCK_IDENTITIES.lock().unwrap();\n            let identities = ids.entry(tokio::task::try_id()).or_default();\n            let id = this.identity();\n            identities.push(id);\n            // eprintln!(\"Locking {} @ {:?}\", this.identity(), tokio::task::try_id());\n            this\n        }\n\n        fn identity(&self) -> String {\n            format!(\n                \"{:?}@{}\",\n                type_name::<T>(),\n                tokio::task::try_id().map_or(\"unknown\".to_string(), |id| id.to_string())\n            )\n        }\n    }\n\n    impl<T> Deref for MutexGuard<'_, T> {\n        type Target = T;\n\n        fn deref(&self) -> &Self::Target {\n            &self.inner\n        }\n    }\n\n    impl<T> DerefMut for MutexGuard<'_, T> {\n        fn deref_mut(&mut self) -> &mut Self::Target {\n            &mut self.inner\n        }\n    }\n\n    impl<T> Drop for MutexGuard<'_, T> {\n        fn drop(&mut self) {\n            let self_id = self.identity();\n            // eprintln!(\"Unlocking {} @ {:?}\", self.identity(), tokio::task::try_id());\n\n            #[allow(clippy::panic)]\n            if self.poisoned.load(Ordering::Relaxed) {\n                eprintln!(\"[!!] MutexGuard dropped while poisoned\");\n                log_state();\n                panic!();\n            }\n\n            #[allow(clippy::unwrap_used)]\n            let mut ids = LOCK_IDENTITIES.lock().unwrap();\n            if let Some(identities) = ids.get_mut(&tokio::task::try_id()) {\n                identities.retain(|id| id != &self_id);\n            }\n        }\n    }\n\n    pub struct Mutex<T> {\n        inner: tokio::sync::Mutex<T>,\n        poisoned: AtomicBool,\n    }\n\n    impl<T> Mutex<T> {\n        pub fn new(data: T) -> Self {\n            Self {\n                inner: tokio::sync::Mutex::new(data),\n                poisoned: AtomicBool::new(false),\n            }\n        }\n\n        pub async fn lock(&self) -> MutexGuard<'_, T> {\n            self._lock().await\n        }\n\n        #[allow(clippy::panic)]\n        async fn _lock(&self) -> MutexGuard<'_, T> {\n            use std::time::Duration;\n\n            tokio::select! {\n                res = self.inner.lock() => MutexGuard::new(res, &self.poisoned),\n                _ = tokio::time::sleep(Duration::from_secs(5)) => {\n                    self.poisoned.store(true, Ordering::Relaxed);\n                    eprintln!(\"[!!] Mutex lock took too long\");\n                    log_state();\n                    panic!();\n                }\n            }\n        }\n    }\n}\n\n#[cfg(debug_assertions)]\npub use deadlock_detecting_mutex::{Mutex, MutexGuard};\n#[cfg(not(debug_assertions))]\npub use tokio::sync::{Mutex, MutexGuard};\n"
  },
  {
    "path": "warpgate-common/src/helpers/mod.rs",
    "content": "pub mod fs;\npub mod hash;\npub mod locks;\npub mod otp;\npub mod rng;\npub mod serde_base64;\npub mod serde_base64_secret;\npub mod websocket;\n"
  },
  {
    "path": "warpgate-common/src/helpers/otp.rs",
    "content": "use std::time::SystemTime;\n\nuse rand::Rng;\nuse totp_rs::{Algorithm, TOTP};\n\nuse super::rng::get_crypto_rng;\nuse crate::types::Secret;\n\npub type OtpExposedSecretKey = Vec<u8>;\npub type OtpSecretKey = Secret<OtpExposedSecretKey>;\n\npub fn generate_key() -> OtpSecretKey {\n    Secret::new(get_crypto_rng().gen::<[u8; 32]>().into())\n}\n\npub fn generate_setup_url(key: &OtpSecretKey, label: &str) -> Secret<String> {\n    let totp = get_totp(key, Some(label));\n    Secret::new(totp.get_url())\n}\n\nfn get_totp(key: &OtpSecretKey, label: Option<&str>) -> TOTP {\n    TOTP {\n        algorithm: Algorithm::SHA1,\n        digits: 6,\n        skew: 1,\n        step: 30,\n        secret: key.expose_secret().clone(),\n        issuer: Some(\"Warpgate\".to_string()),\n        account_name: label.unwrap_or(\"\").to_string(),\n    }\n}\n\npub fn verify_totp(code: &str, key: &OtpSecretKey) -> bool {\n    #[allow(clippy::unwrap_used)]\n    let time = SystemTime::now()\n        .duration_since(SystemTime::UNIX_EPOCH)\n        .unwrap()\n        .as_secs();\n    get_totp(key, None).check(code, time)\n}\n"
  },
  {
    "path": "warpgate-common/src/helpers/rng.rs",
    "content": "use rand::SeedableRng;\nuse rand_chacha::ChaCha20Rng;\n\npub fn get_crypto_rng() -> ChaCha20Rng {\n    ChaCha20Rng::from_entropy()\n}\n"
  },
  {
    "path": "warpgate-common/src/helpers/serde_base64.rs",
    "content": "use data_encoding::BASE64;\nuse serde::{Deserialize, Serializer};\n\npub fn serialize<S: Serializer, B: AsRef<[u8]>>(\n    bytes: B,\n    serializer: S,\n) -> Result<S::Ok, S::Error> {\n    serializer.serialize_str(&BASE64.encode(bytes.as_ref()))\n}\n\npub fn deserialize<'de, D: serde::Deserializer<'de>, B: From<Vec<u8>>>(\n    deserializer: D,\n) -> Result<B, D::Error> {\n    let s = String::deserialize(deserializer)?;\n    Ok(BASE64\n        .decode(s.as_bytes())\n        .map_err(serde::de::Error::custom)?\n        .into())\n}\n"
  },
  {
    "path": "warpgate-common/src/helpers/serde_base64_secret.rs",
    "content": "use serde::Serializer;\n\nuse super::serde_base64;\nuse crate::Secret;\n\npub fn serialize<S: Serializer>(\n    secret: &Secret<Vec<u8>>,\n    serializer: S,\n) -> Result<S::Ok, S::Error> {\n    serde_base64::serialize(secret.expose_secret(), serializer)\n}\n\npub fn deserialize<'de, D: serde::Deserializer<'de>>(\n    deserializer: D,\n) -> Result<Secret<Vec<u8>>, D::Error> {\n    let inner = serde_base64::deserialize(deserializer)?;\n    Ok(Secret::new(inner))\n}\n"
  },
  {
    "path": "warpgate-common/src/helpers/websocket.rs",
    "content": "use std::future::Future;\n\nuse futures::{Sink, SinkExt, Stream, StreamExt};\nuse poem::web::websocket::Message;\nuse tokio_tungstenite::tungstenite::{self, Utf8Bytes};\n\npub trait TungsteniteCompatibleWebsocketMessage {\n    fn to_tungstenite_message(self) -> tungstenite::Message;\n    fn from_tungstenite_message(m: tungstenite::Message) -> Self;\n}\n\nimpl TungsteniteCompatibleWebsocketMessage for Message {\n    fn to_tungstenite_message(self) -> tungstenite::Message {\n        match self {\n            Message::Binary(data) => tungstenite::Message::Binary(data.into()),\n            Message::Text(text) => tungstenite::Message::Text(text.into()),\n            Message::Ping(data) => tungstenite::Message::Ping(data.into()),\n            Message::Pong(data) => tungstenite::Message::Pong(data.into()),\n            Message::Close(data) => {\n                tungstenite::Message::Close(data.map(|data| tungstenite::protocol::CloseFrame {\n                    code: u16::from(data.0).into(),\n                    reason: Utf8Bytes::from(data.1),\n                }))\n            }\n        }\n    }\n\n    fn from_tungstenite_message(msg: tungstenite::Message) -> Self {\n        match msg {\n            tungstenite::Message::Binary(data) => Message::Binary(data.to_vec()),\n            tungstenite::Message::Text(text) => Message::Text(text.to_string()),\n            tungstenite::Message::Ping(data) => Message::Ping(data.to_vec()),\n            tungstenite::Message::Pong(data) => Message::Pong(data.to_vec()),\n            tungstenite::Message::Close(data) => Message::Close(\n                data.map(|data| (u16::from(data.code).into(), data.reason.to_string())),\n            ),\n            tungstenite::Message::Frame(_) => unreachable!(),\n        }\n    }\n}\n\nimpl TungsteniteCompatibleWebsocketMessage for reqwest_websocket::Message {\n    fn to_tungstenite_message(self) -> tungstenite::Message {\n        match self {\n            reqwest_websocket::Message::Binary(data) => tungstenite::Message::Binary(data),\n            reqwest_websocket::Message::Text(text) => {\n                tungstenite::Message::Text(Utf8Bytes::from(text))\n            }\n            reqwest_websocket::Message::Ping(data) => tungstenite::Message::Ping(data),\n            reqwest_websocket::Message::Pong(data) => tungstenite::Message::Pong(data),\n            reqwest_websocket::Message::Close { code, reason } => {\n                tungstenite::Message::Close(Some(tungstenite::protocol::CloseFrame {\n                    code: u16::from(code).into(),\n                    reason: Utf8Bytes::from(reason),\n                }))\n            }\n        }\n    }\n\n    fn from_tungstenite_message(msg: tungstenite::Message) -> Self {\n        match msg {\n            tungstenite::Message::Binary(data) => reqwest_websocket::Message::Binary(data),\n            tungstenite::Message::Text(text) => reqwest_websocket::Message::Text(text.to_string()),\n            tungstenite::Message::Ping(data) => reqwest_websocket::Message::Ping(data),\n            tungstenite::Message::Pong(data) => reqwest_websocket::Message::Pong(data),\n            tungstenite::Message::Close(data) => reqwest_websocket::Message::Close {\n                code: data\n                    .as_ref()\n                    .map(|data| u16::from(data.code).into())\n                    .unwrap_or(reqwest_websocket::CloseCode::Normal),\n                reason: data.map(|data| data.reason.to_string()).unwrap_or_default(),\n            },\n            tungstenite::Message::Frame(_) => unreachable!(),\n        }\n    }\n}\n\nimpl TungsteniteCompatibleWebsocketMessage for tungstenite::Message {\n    fn to_tungstenite_message(self) -> tungstenite::Message {\n        self\n    }\n\n    fn from_tungstenite_message(msg: tungstenite::Message) -> Self {\n        msg\n    }\n}\n\npub async fn pump_websocket<\n    DM: TungsteniteCompatibleWebsocketMessage + Send,\n    D: Sink<DM> + Send + Unpin,\n    SM: TungsteniteCompatibleWebsocketMessage + Send,\n    SE: Send,\n    S: Stream<Item = Result<SM, SE>> + Send + Unpin,\n    FE: Send,\n    F: FnMut(tungstenite::Message) -> Fut,\n    Fut: Future<Output = Result<tungstenite::Message, FE>> + Send,\n>(\n    mut source: S,\n    mut sink: D,\n    mut callback: F,\n) -> anyhow::Result<()>\nwhere\n    anyhow::Error: From<D::Error>,\n    anyhow::Error: From<SE>,\n    anyhow::Error: From<FE>,\n{\n    while let Some(msg) = source.next().await {\n        let msg = msg?.to_tungstenite_message();\n        let msg = callback(msg).await?;\n        sink.send(DM::from_tungstenite_message(msg)).await?;\n    }\n    Ok::<_, anyhow::Error>(())\n}\n"
  },
  {
    "path": "warpgate-common/src/http_headers.rs",
    "content": "use std::collections::HashSet;\n\nuse once_cell::sync::Lazy;\nuse poem::http::{self, HeaderName};\n\n/// Headers that should not be forwarded to upstream when proxying HTTP requests\npub static DONT_FORWARD_HEADERS: Lazy<HashSet<HeaderName>> = Lazy::new(|| {\n    #[allow(clippy::mutable_key_type)]\n    let mut s = HashSet::new();\n    s.insert(http::header::ACCEPT_ENCODING);\n    s.insert(http::header::SEC_WEBSOCKET_EXTENSIONS);\n    s.insert(http::header::SEC_WEBSOCKET_ACCEPT);\n    s.insert(http::header::SEC_WEBSOCKET_KEY);\n    s.insert(http::header::SEC_WEBSOCKET_VERSION);\n    s.insert(http::header::UPGRADE);\n    s.insert(http::header::HOST);\n    s.insert(http::header::CONNECTION);\n    s.insert(http::header::STRICT_TRANSPORT_SECURITY);\n    s.insert(http::header::UPGRADE_INSECURE_REQUESTS);\n    s\n});\n\npub static X_FORWARDED_FOR: HeaderName = HeaderName::from_static(\"x-forwarded-for\");\npub static X_FORWARDED_HOST: HeaderName = HeaderName::from_static(\"x-forwarded-host\");\npub static X_FORWARDED_PROTO: HeaderName = HeaderName::from_static(\"x-forwarded-proto\");\n"
  },
  {
    "path": "warpgate-common/src/lib.rs",
    "content": "pub mod api;\npub mod auth;\nmod config;\npub mod consts;\nmod error;\npub mod eventhub;\npub mod helpers;\npub mod http_headers;\nmod state;\nmod try_macro;\nmod types;\npub mod version;\n\npub use config::*;\npub use error::WarpgateError;\npub use state::GlobalParams;\npub use types::*;\n"
  },
  {
    "path": "warpgate-common/src/state.rs",
    "content": "use std::path::PathBuf;\n\nuse anyhow::Context;\n\n#[derive(Clone)]\npub struct GlobalParams {\n    config_path: PathBuf,\n    should_secure_files: bool,\n    paths_relative_to: PathBuf,\n}\n\nimpl GlobalParams {\n    pub fn new(config_path: PathBuf, should_secure_files: bool) -> anyhow::Result<Self> {\n        Ok(Self {\n            paths_relative_to: config_path\n                .parent()\n                .context(\"Failed to determine config parent directory\")?\n                .to_path_buf(),\n            config_path,\n            should_secure_files,\n        })\n    }\n\n    pub fn config_path(&self) -> &PathBuf {\n        &self.config_path\n    }\n\n    pub fn paths_relative_to(&self) -> &PathBuf {\n        &self.paths_relative_to\n    }\n\n    pub fn should_secure_files(&self) -> bool {\n        self.should_secure_files\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/try_macro.rs",
    "content": "#[macro_export]\nmacro_rules! try_block {\n    ($try:block catch ($err:ident : $errtype:ty) $catch:block) => {{\n        #[allow(unreachable_code)]\n        let result: anyhow::Result<_, $errtype> = (|| Ok::<_, $errtype>($try))();\n        match result {\n            Ok(_) => (),\n            Err($err) => {\n                {\n                    $catch\n                };\n            }\n        };\n    }};\n    (async $try:block catch ($err:ident : $errtype:ty) $catch:block) => {{\n        let result: anyhow::Result<_, $errtype> = (async { Ok::<_, $errtype>($try) }).await;\n        match result {\n            Ok(_) => (),\n            Err($err) => {\n                {\n                    $catch\n                };\n            }\n        };\n    }};\n}\n\n#[test]\n#[allow(clippy::assertions_on_constants)]\n#[allow(clippy::unwrap_used)]\nfn test_catch() {\n    let mut caught = false;\n    try_block!({\n        let _: u32 = \"asdf\".parse()?;\n        assert!(false)\n    } catch (e: anyhow::Error) {\n        assert_eq!(e.to_string(), \"asdf\".parse::<i32>().unwrap_err().to_string());\n        caught = true;\n    });\n    assert!(caught);\n}\n\n#[test]\n#[allow(clippy::assertions_on_constants)]\nfn test_success() {\n    try_block!({\n        let _: u32 = \"123\".parse()?;\n    } catch (_e: anyhow::Error) {\n        assert!(false)\n    });\n}\n"
  },
  {
    "path": "warpgate-common/src/types/aliases.rs",
    "content": "use uuid::Uuid;\n\npub type SessionId = Uuid;\npub type ProtocolName = &'static str;\n"
  },
  {
    "path": "warpgate-common/src/types/listen_endpoint.rs",
    "content": "use std::fmt::Debug;\nuse std::io::ErrorKind;\nuse std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};\n\nuse futures::stream::{iter, FuturesUnordered};\nuse futures::{Stream, StreamExt, TryStreamExt};\nuse poem::listener::Listener;\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse tokio::net::{TcpListener, TcpStream};\nuse tokio_stream::wrappers::TcpListenerStream;\n\nuse crate::WarpgateError;\n\n#[derive(Clone, JsonSchema)]\npub struct ListenEndpoint(SocketAddr);\n\nimpl ListenEndpoint {\n    pub fn address(&self) -> SocketAddr {\n        self.0\n    }\n\n    pub fn addresses_to_listen_on(&self) -> Result<Vec<SocketAddr>, WarpgateError> {\n        // For [::], explicitly return both addresses so that we are not affected\n        // by the state of the ipv6only sysctl.\n        if self.0.ip() == Ipv6Addr::UNSPECIFIED {\n            let addr6 = SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), self.0.port());\n            let addr4 = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), self.0.port());\n            let listener6 = std::net::TcpListener::bind(addr6)?;\n            let listener4 = std::net::TcpListener::bind(addr4);\n            let result = match listener4 {\n                Ok(_) => vec![addr4, addr6],\n                Err(e) if e.kind() == ErrorKind::AddrInUse => vec![addr6],\n                Err(e) => return Err(WarpgateError::Io(e)),\n            };\n            drop(listener6);\n            Ok(result)\n        } else {\n            Ok(vec![self.0])\n        }\n    }\n\n    pub async fn tcp_listeners(&self) -> Result<Vec<TcpListener>, WarpgateError> {\n        Ok(self\n            .addresses_to_listen_on()?\n            .into_iter()\n            .map(TcpListener::bind)\n            .collect::<FuturesUnordered<_>>()\n            .try_collect()\n            .await?)\n    }\n\n    pub async fn poem_listener(&self) -> Result<poem::listener::BoxListener, WarpgateError> {\n        let addrs = self.addresses_to_listen_on()?;\n        #[allow(clippy::unwrap_used)] // length known >=1\n        let (first, rest) = addrs.split_first().unwrap();\n        let mut listener: poem::listener::BoxListener =\n            poem::listener::TcpListener::bind(first.to_string()).boxed();\n        for addr in rest {\n            listener = listener\n                .combine(poem::listener::TcpListener::bind(addr.to_string()))\n                .boxed();\n        }\n\n        Ok(listener)\n    }\n\n    pub async fn tcp_accept_stream(\n        &self,\n    ) -> Result<impl Stream<Item = std::io::Result<TcpStream>>, WarpgateError> {\n        Ok(iter(\n            self.tcp_listeners()\n                .await?\n                .into_iter()\n                .map(TcpListenerStream::new),\n        )\n        .flatten_unordered(None))\n    }\n\n    pub fn port(&self) -> u16 {\n        self.0.port()\n    }\n}\n\nimpl From<SocketAddr> for ListenEndpoint {\n    fn from(addr: SocketAddr) -> Self {\n        Self(addr)\n    }\n}\n\nimpl<'de> Deserialize<'de> for ListenEndpoint {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: serde::Deserializer<'de>,\n    {\n        let v: String = Deserialize::deserialize::<D>(deserializer)?;\n        let v = v\n            .to_socket_addrs()\n            .map_err(|e| {\n                serde::de::Error::custom(format!(\n                    \"failed to resolve {v} into a TCP endpoint: {e:?}\"\n                ))\n            })?\n            .next()\n            .ok_or_else(|| {\n                serde::de::Error::custom(format!(\"failed to resolve {v} into a TCP endpoint\"))\n            })?;\n        Ok(Self(v))\n    }\n}\n\nimpl Serialize for ListenEndpoint {\n    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>\n    where\n        S: serde::Serializer,\n    {\n        self.0.serialize(serializer)\n    }\n}\n\nimpl Debug for ListenEndpoint {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        self.0.fmt(f)\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/types/mod.rs",
    "content": "mod aliases;\nmod listen_endpoint;\nmod secret;\n\npub use aliases::*;\npub use listen_endpoint::*;\npub use secret::*;\n"
  },
  {
    "path": "warpgate-common/src/types/secret.rs",
    "content": "use std::borrow::Cow;\nuse std::fmt::Debug;\n\nuse bytes::Bytes;\nuse data_encoding::HEXLOWER;\nuse delegate::delegate;\nuse poem_openapi::registry::{MetaSchemaRef, Registry};\nuse poem_openapi::types::{ParseError, ParseFromJSON, ToJSON};\nuse rand::Rng;\nuse serde::{Deserialize, Serialize};\n\nuse crate::helpers::rng::get_crypto_rng;\n\n#[derive(PartialEq, Eq, Clone)]\npub struct Secret<T>(T);\n\nimpl Secret<String> {\n    pub fn random() -> Self {\n        Secret::new(HEXLOWER.encode(&Bytes::from_iter(get_crypto_rng().gen::<[u8; 32]>())))\n    }\n}\n\nimpl<T> Secret<T> {\n    pub const fn new(v: T) -> Self {\n        Self(v)\n    }\n\n    pub fn expose_secret(&self) -> &T {\n        &self.0\n    }\n}\n\nimpl<T> From<T> for Secret<T> {\n    fn from(v: T) -> Self {\n        Self::new(v)\n    }\n}\n\nimpl<'de, T> Deserialize<'de> for Secret<T>\nwhere\n    T: Deserialize<'de>,\n{\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: serde::Deserializer<'de>,\n    {\n        let v = Deserialize::deserialize::<D>(deserializer)?;\n        Ok(Self::new(v))\n    }\n}\n\nimpl<T> Serialize for Secret<T>\nwhere\n    T: Serialize,\n{\n    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>\n    where\n        S: serde::Serializer,\n    {\n        self.0.serialize(serializer)\n    }\n}\n\nimpl<T> Debug for Secret<T> {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"<secret>\")\n    }\n}\n\nimpl<T: poem_openapi::types::Type> poem_openapi::types::Type for Secret<T> {\n    const IS_REQUIRED: bool = T::IS_REQUIRED;\n    type RawValueType = T::RawValueType;\n    type RawElementValueType = T::RawElementValueType;\n\n    fn name() -> Cow<'static, str> {\n        T::name()\n    }\n    fn schema_ref() -> MetaSchemaRef {\n        T::schema_ref()\n    }\n    fn register(registry: &mut Registry) {\n        T::register(registry)\n    }\n\n    delegate! {\n        to self.0 {\n            fn as_raw_value(&self) -> Option<&Self::RawValueType>;\n            fn raw_element_iter(\n                &'_ self,\n            ) -> Box<dyn Iterator<Item = &'_ Self::RawElementValueType> + '_>;\n            fn is_empty(&self) -> bool;\n            fn is_none(&self) -> bool;\n        }\n    }\n}\n\nimpl<T: ParseFromJSON> ParseFromJSON for Secret<T> {\n    fn parse_from_json(value: Option<serde_json::Value>) -> poem_openapi::types::ParseResult<Self> {\n        T::parse_from_json(value)\n            .map(Self::new)\n            .map_err(|e| ParseError::custom(e.into_message()))\n    }\n}\n\nimpl<T: ToJSON> ToJSON for Secret<T> {\n    fn to_json(&self) -> Option<serde_json::Value> {\n        self.0.to_json()\n    }\n}\n"
  },
  {
    "path": "warpgate-common/src/version.rs",
    "content": "use git_version::git_version;\n\npub fn warpgate_version() -> &'static str {\n    git_version!(\n        args = [\"--tags\", \"--always\", \"--dirty=-modified\"],\n        fallback = \"unknown\"\n    )\n}\n"
  },
  {
    "path": "warpgate-common-http/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-common-http\"\nversion = \"0.22.0\"\n\n[dependencies]\nwarpgate-common = { path = \"../warpgate-common\" }\nwarpgate-core = { path = \"../warpgate-core\" }\npoem.workspace = true\npoem-openapi.workspace = true\nserde.workspace = true\ntokio.workspace = true\ntracing.workspace = true\nuuid.workspace = true\n"
  },
  {
    "path": "warpgate-common-http/src/auth.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n#[derive(Clone, Serialize, Deserialize)]\npub struct AuthStateId(pub Uuid);\n\n/// Represents the source of authentication of a session\n#[derive(Clone, Serialize, Deserialize, Debug)]\npub enum SessionAuthorization {\n    User(String),\n    Ticket {\n        username: String,\n        target_name: String,\n    },\n}\n\nimpl SessionAuthorization {\n    pub fn username(&self) -> &String {\n        match self {\n            Self::User(username) => username,\n            Self::Ticket { username, .. } => username,\n        }\n    }\n}\n\n/// Represents the source of authentication in a request\n#[derive(Clone, Serialize, Deserialize, Debug)]\npub enum RequestAuthorization {\n    Session(SessionAuthorization),\n    UserToken { username: String },\n    AdminToken,\n}\n\n#[derive(Clone)]\npub struct UnauthenticatedRequestContext {\n    pub services: warpgate_core::Services,\n}\n\n/// Provided to API handlers as Data<>\nimpl UnauthenticatedRequestContext {\n    pub fn to_authenticated(&self, auth: RequestAuthorization) -> AuthenticatedRequestContext {\n        AuthenticatedRequestContext {\n            auth,\n            services: self.services.clone(),\n        }\n    }\n}\n\n#[derive(Clone)]\n/// Provided to API handlers as Data<> when a request is authenticated\npub struct AuthenticatedRequestContext {\n    pub auth: RequestAuthorization,\n    pub services: warpgate_core::Services,\n}\n\nimpl RequestAuthorization {\n    /// Returns a username if one is present (admin token has none)\n    pub fn username(&self) -> Option<&String> {\n        match self {\n            Self::Session(auth) => Some(auth.username()),\n            Self::UserToken { username } => Some(username),\n            Self::AdminToken => None,\n        }\n    }\n}\n\n/// Check if a host is localhost or 127.x.x.x (for development/testing scenarios)\npub fn is_localhost_host(host: &str) -> bool {\n    host == \"localhost\" || host == \"127.0.0.1\" || host.starts_with(\"127.\")\n}\n"
  },
  {
    "path": "warpgate-common-http/src/lib.rs",
    "content": "pub mod auth;\npub mod logging;\n\npub use auth::{AuthenticatedRequestContext, RequestAuthorization, SessionAuthorization};\nuse poem::http::HeaderName;\n\npub static X_WARPGATE_TOKEN: HeaderName = HeaderName::from_static(\"x-warpgate-token\");\n"
  },
  {
    "path": "warpgate-common-http/src/logging.rs",
    "content": "use std::fmt::Debug;\nuse std::net::ToSocketAddrs;\n\nuse poem::http::{Method, StatusCode, Uri};\nuse poem::web::RemoteAddr;\nuse poem::{Addr, Request};\nuse tracing::*;\nuse warpgate_core::{Services, WarpgateServerHandle};\n\npub async fn get_client_ip(req: &Request, services: &Services) -> Option<String> {\n    let trust_x_forwarded_headers = {\n        let config = services.config.lock().await;\n        config.store.http.trust_x_forwarded_headers\n    };\n\n    let socket_addr = match req.remote_addr() {\n        // See [CertificateExtractorEndpoint]\n        RemoteAddr(Addr::Custom(\"captured-cert\", value)) => {\n            #[allow(clippy::unwrap_used)]\n            let original_remote_addr = value.split(\"|\").next().unwrap();\n            original_remote_addr\n                .to_socket_addrs()\n                .ok()\n                .and_then(|i| i.into_iter().next())\n        }\n        other => other.as_socket_addr().cloned(),\n    };\n\n    let remote_ip = socket_addr.map(|x| x.ip().to_string());\n\n    if trust_x_forwarded_headers {\n        req.header(\"x-forwarded-for\")\n            .map(|x| x.to_string())\n            .or(remote_ip)\n    } else {\n        remote_ip\n    }\n}\n\npub async fn span_for_request(\n    req: &Request,\n    services: &Services,\n    handle: Option<&WarpgateServerHandle>,\n) -> poem::Result<Span> {\n    let client_ip = get_client_ip(req, services)\n        .await\n        .unwrap_or(\"<unknown>\".into());\n\n    Ok(match handle {\n        Some(handle) => {\n            let ss = handle.session_state().lock().await;\n            match ss.user_info.clone() {\n                Some(ref user_info) => {\n                    info_span!(\"HTTP\", session=%handle.id(), session_username=%user_info.username, %client_ip)\n                }\n                None => info_span!(\"HTTP\", session=%handle.id(), %client_ip),\n            }\n        }\n        None => info_span!(\"HTTP\"),\n    })\n}\n\npub fn log_request_result(\n    method: &Method,\n    url: &Uri,\n    client_ip: Option<&str>,\n    status: &StatusCode,\n) {\n    let client_ip = client_ip.unwrap_or(\"<unknown>\");\n    if status.is_server_error() || status.is_client_error() {\n        warn!(%method, %url, %status, %client_ip, \"Request failed\");\n    } else {\n        info!(%method, %url, %status, %client_ip, \"Request\");\n    }\n}\n\npub fn log_request_error<E: Debug>(method: &Method, url: &Uri, client_ip: Option<&str>, error: &E) {\n    let client_ip = client_ip.unwrap_or(\"<unknown>\");\n    error!(%method, %url, ?error, %client_ip, \"Request failed\");\n}\n"
  },
  {
    "path": "warpgate-core/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-core\"\nversion = \"0.22.0\"\n\n[dependencies]\nwarpgate-common = { version = \"*\", path = \"../warpgate-common\" }\nwarpgate-db-entities = { version = \"*\", path = \"../warpgate-db-entities\" }\nwarpgate-db-migrations = { version = \"*\", path = \"../warpgate-db-migrations\" }\nwarpgate-ldap = { version = \"*\", path = \"../warpgate-ldap\" }\n\nanyhow.workspace = true\nargon2 = \"0.5\"\nasync-trait = \"0.1\"\nbytes.workspace = true\nchrono = { version = \"0.4\", default-features = false, features = [\"serde\"] }\ndata-encoding.workspace = true\ndialoguer.workspace = true\nenum_dispatch.workspace = true\nhumantime-serde = \"1.1\"\nfutures.workspace = true\nldap3.workspace = true\nonce_cell = \"1.17\"\npacket = \"0.1\"\npassword-hash.workspace = true\npoem.workspace = true\npoem-openapi.workspace = true\nrand.workspace = true\nrand_chacha.workspace = true\nrand_core.workspace = true\nrussh.workspace = true\nsea-orm.workspace = true\nserde.workspace = true\nserde_json.workspace = true\nthiserror.workspace = true\ntokio.workspace = true\ntotp-rs = { version = \"5.0\", features = [\"otpauth\"], default-features = false }\ntracing.workspace = true\ntracing-core = { version = \"0.1\", default-features = false }\ntracing-subscriber = { version = \"0.3\", default-features = false }\nurl = { version = \"2.2\", default-features = false }\nuuid.workspace = true\nwarpgate-sso = { version = \"*\", path = \"../warpgate-sso\", default-features = false }\nrustls.workspace = true\nwebpki = { version = \"0.22\", default-features = false }\ngovernor.workspace = true\n\n[features]\npostgres = [\"sea-orm/sqlx-postgres\"]\nmysql = [\"sea-orm/sqlx-mysql\"]\nsqlite = [\"sea-orm/sqlx-sqlite\"]\n"
  },
  {
    "path": "warpgate-core/src/auth_state_store.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\n\nuse once_cell::sync::Lazy;\nuse tokio::sync::{broadcast, Mutex};\nuse uuid::Uuid;\nuse warpgate_common::auth::{AuthResult, AuthState, CredentialKind};\nuse warpgate_common::{SessionId, WarpgateError};\n\nuse crate::{ConfigProvider, ConfigProviderEnum};\n\n#[allow(clippy::unwrap_used)]\npub static TIMEOUT: Lazy<Duration> = Lazy::new(|| Duration::from_secs(60 * 10));\n\nstruct AuthCompletionSignal {\n    sender: broadcast::Sender<AuthResult>,\n    created_at: Instant,\n}\n\nimpl AuthCompletionSignal {\n    pub fn is_expired(&self) -> bool {\n        self.created_at.elapsed() > *TIMEOUT\n    }\n}\n\npub struct AuthStateStore {\n    config_provider: Arc<Mutex<ConfigProviderEnum>>,\n    store: HashMap<Uuid, (Arc<Mutex<AuthState>>, Instant)>,\n    completion_signals: HashMap<Uuid, AuthCompletionSignal>,\n    web_auth_request_signal: broadcast::Sender<Uuid>,\n}\n\nimpl AuthStateStore {\n    pub fn new(config_provider: Arc<Mutex<ConfigProviderEnum>>) -> Self {\n        Self {\n            store: HashMap::new(),\n            config_provider,\n            completion_signals: HashMap::new(),\n            web_auth_request_signal: broadcast::channel(100).0,\n        }\n    }\n\n    pub fn contains_key(&self, id: &Uuid) -> bool {\n        self.store.contains_key(id)\n    }\n\n    pub async fn all_pending_web_auths_for_user(\n        &self,\n        username: &str,\n    ) -> Vec<Arc<Mutex<AuthState>>> {\n        let mut results = vec![];\n        for auth in self.store.values() {\n            {\n                let inner = auth.0.lock().await;\n                if inner.user_info().username != username {\n                    continue;\n                }\n                let AuthResult::Need(need) = inner.verify() else {\n                    continue;\n                };\n                if !need.contains(&CredentialKind::WebUserApproval) {\n                    continue;\n                }\n            }\n            results.push(auth.0.clone())\n        }\n        results\n    }\n\n    pub fn get(&self, id: &Uuid) -> Option<Arc<Mutex<AuthState>>> {\n        self.store.get(id).map(|x| x.0.clone())\n    }\n\n    pub fn subscribe_web_auth_request(&self) -> broadcast::Receiver<Uuid> {\n        self.web_auth_request_signal.subscribe()\n    }\n\n    pub async fn create(\n        &mut self,\n        session_id: Option<&SessionId>,\n        username: &str,\n        protocol: &str,\n        supported_credential_types: &[CredentialKind],\n    ) -> Result<(Uuid, Arc<Mutex<AuthState>>), WarpgateError> {\n        let id = Uuid::new_v4();\n\n        let Some(user) = self\n            .config_provider\n            .lock()\n            .await\n            .list_users()\n            .await?\n            .iter()\n            .find(|u| u.username == username)\n            .cloned()\n        else {\n            return Err(WarpgateError::UserNotFound(username.into()));\n        };\n\n        let policy = self\n            .config_provider\n            .lock()\n            .await\n            .get_credential_policy(username, supported_credential_types)\n            .await?;\n        let Some(policy) = policy else {\n            return Err(WarpgateError::UserNotFound(username.into()));\n        };\n\n        let (state_change_tx, mut state_change_rx) = broadcast::channel(1);\n        let web_auth_request_signal = self.web_auth_request_signal.clone();\n        tokio::spawn(async move {\n            while let Ok(AuthResult::Need(result)) = state_change_rx.recv().await {\n                if result.contains(&CredentialKind::WebUserApproval) {\n                    let _ = web_auth_request_signal.send(id);\n                }\n            }\n        });\n\n        let state = AuthState::new(\n            id,\n            session_id.copied(),\n            (&user).into(),\n            protocol.to_string(),\n            policy,\n            state_change_tx,\n        );\n        self.store\n            .insert(id, (Arc::new(Mutex::new(state)), Instant::now()));\n\n        #[allow(clippy::unwrap_used)]\n        Ok((id, self.get(&id).unwrap()))\n    }\n\n    pub fn subscribe(&mut self, id: Uuid) -> broadcast::Receiver<AuthResult> {\n        let signal = self.completion_signals.entry(id).or_insert_with(|| {\n            let (sender, _) = broadcast::channel(1);\n            AuthCompletionSignal {\n                sender,\n                created_at: Instant::now(),\n            }\n        });\n\n        signal.sender.subscribe()\n    }\n\n    pub async fn complete(&mut self, id: &Uuid) {\n        let Some((state, _)) = self.store.get(id) else {\n            return;\n        };\n        if let Some(sig) = self.completion_signals.remove(id) {\n            let _ = sig.sender.send(state.lock().await.verify());\n        }\n    }\n\n    pub async fn vacuum(&mut self) {\n        self.store\n            .retain(|_, (_, started_at)| started_at.elapsed() < *TIMEOUT);\n\n        self.completion_signals\n            .retain(|_, signal| !signal.is_expired());\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/config_providers/db.rs",
    "content": "use std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse data_encoding::BASE64;\nuse sea_orm::{\n    ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter,\n    QueryOrder, Set,\n};\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::auth::{\n    AllCredentialsPolicy, AnySingleCredentialPolicy, AuthCredential, CredentialKind,\n    CredentialPolicy, PerProtocolCredentialPolicy,\n};\nuse warpgate_common::helpers::hash::verify_password_hash;\nuse warpgate_common::helpers::otp::verify_totp;\nuse warpgate_common::{\n    Role, Target, User, UserAuthCredential, UserPasswordCredential, UserPublicKeyCredential,\n    UserRequireCredentialsPolicy, UserSsoCredential, UserTotpCredential, WarpgateError,\n};\nuse warpgate_db_entities as entities;\nuse warpgate_sso::SsoProviderConfig;\n\nuse super::ConfigProvider;\n\npub struct DatabaseConfigProvider {\n    db: Arc<Mutex<DatabaseConnection>>,\n}\n\nimpl DatabaseConfigProvider {\n    pub async fn new(db: &Arc<Mutex<DatabaseConnection>>) -> Self {\n        Self { db: db.clone() }\n    }\n\n    async fn sync_ldap_ssh_keys(\n        &self,\n        db: &DatabaseConnection,\n        user_id: Uuid,\n        ldap_server_id: Uuid,\n        ldap_object_uuid: &Uuid,\n    ) -> Result<(), WarpgateError> {\n        // Fetch LDAP server config\n        let ldap_server = entities::LdapServer::Entity::find_by_id(ldap_server_id)\n            .one(db)\n            .await?\n            .ok_or_else(|| {\n                warpgate_ldap::LdapError::InvalidConfiguration(\"LDAP server not found\".to_string())\n            })?;\n\n        if !ldap_server.enabled {\n            debug!(\n                \"LDAP server {} is disabled, skipping SSH key sync\",\n                ldap_server.name\n            );\n            return Ok(());\n        }\n\n        let ldap_config = warpgate_ldap::LdapConfig::try_from(&ldap_server)?;\n\n        // Find user in LDAP by object UUID\n        let ldap_user = warpgate_ldap::find_user_by_uuid(&ldap_config, ldap_object_uuid).await?;\n\n        let Some(ldap_user) = ldap_user else {\n            warn!(\n                \"LDAP user with UUID {} not found in server {}\",\n                ldap_object_uuid, ldap_server.name\n            );\n            return Ok(());\n        };\n\n        // Delete existing public key credentials for this user\n        entities::PublicKeyCredential::Entity::delete_many()\n            .filter(entities::PublicKeyCredential::Column::UserId.eq(user_id))\n            .exec(db)\n            .await?;\n\n        // Insert SSH keys from LDAP\n        for ssh_key in &ldap_user.ssh_public_keys {\n            let ssh_key = ssh_key.trim();\n            if ssh_key.is_empty() {\n                continue;\n            }\n\n            // Parse and validate the SSH key\n            let key_result = russh::keys::PublicKey::from_openssh(ssh_key);\n            if let Ok(mut key) = key_result {\n                key.set_comment(\"\");\n                let openssh_key = key.to_openssh().map_err(russh::keys::Error::from)?;\n\n                entities::PublicKeyCredential::ActiveModel {\n                    id: Set(Uuid::new_v4()),\n                    user_id: Set(user_id),\n                    date_added: Set(Some(Utc::now())),\n                    last_used: Set(None),\n                    label: Set(\"Public key synchronized from LDAP\".to_string()),\n                    ..entities::PublicKeyCredential::ActiveModel::from(UserPublicKeyCredential {\n                        key: openssh_key.into(),\n                    })\n                }\n                .insert(db)\n                .await?;\n            } else {\n                warn!(\"Invalid SSH key from LDAP: {}\", ssh_key);\n            }\n        }\n\n        info!(\n            \"Synced {} SSH key(s) from LDAP for {}\",\n            ldap_user.ssh_public_keys.len(),\n            ldap_user.username\n        );\n\n        Ok(())\n    }\n\n    async fn maybe_autocreate_sso_user(\n        &self,\n        db: &DatabaseConnection,\n        credential: UserSsoCredential,\n        preferred_username: String,\n        default_credential_policy: Option<serde_json::Value>,\n    ) -> Result<Option<String>, WarpgateError> {\n        // Check for LDAP servers with auto-linking enabled\n        let ldap_servers: Vec<entities::LdapServer::Model> = entities::LdapServer::Entity::find()\n            .filter(entities::LdapServer::Column::Enabled.eq(true))\n            .filter(entities::LdapServer::Column::AutoLinkSsoUsers.eq(true))\n            .all(db)\n            .await?;\n\n        let mut ldap_server_id = None;\n        let mut ldap_object_uuid = None;\n\n        // Try to find user in LDAP servers\n        for ldap_server in ldap_servers {\n            let ldap_config = warpgate_ldap::LdapConfig::try_from(&ldap_server).map_err(|e| {\n                warn!(\n                    \"Failed to parse LDAP config for server {}: {}\",\n                    ldap_server.name, e\n                );\n                e\n            })?;\n\n            match warpgate_ldap::find_user_by_username(&ldap_config, &preferred_username).await {\n                Ok(Some(ldap_user)) => {\n                    info!(\n                        \"Found LDAP user for username {}: {:?}\",\n                        preferred_username, ldap_user.username\n                    );\n                    ldap_server_id = Some(ldap_server.id);\n                    ldap_object_uuid = Some(ldap_user.object_uuid);\n                    break;\n                }\n                Ok(None) => {\n                    debug!(\n                        \"No LDAP user found with username {} in server {}\",\n                        preferred_username, ldap_server.name\n                    );\n                }\n                Err(e) => {\n                    warn!(\n                        \"Error searching for LDAP user in {}: {}\",\n                        ldap_server.name, e\n                    );\n                }\n            }\n        }\n\n        let user = entities::User::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            username: Set(preferred_username.clone()),\n            description: Set(\"\".into()),\n            credential_policy: Set(default_credential_policy.unwrap_or_else(|| {\n                serde_json::to_value(UserRequireCredentialsPolicy::default()).unwrap_or_default()\n            })),\n            rate_limit_bytes_per_second: Set(None),\n            ldap_server_id: Set(ldap_server_id),\n            ldap_object_uuid: Set(ldap_object_uuid),\n        }\n        .insert(db)\n        .await?;\n\n        entities::SsoCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(user.id),\n            ..credential.into()\n        }\n        .insert(db)\n        .await?;\n\n        if ldap_server_id.is_some() {\n            info!(\n                \"Auto-created SSO user {} and linked to LDAP account\",\n                preferred_username\n            );\n        } else {\n            info!(\n                \"Auto-created SSO user {} (no LDAP link)\",\n                preferred_username\n            );\n        }\n\n        Ok(Some(preferred_username))\n    }\n}\n\nimpl ConfigProvider for DatabaseConfigProvider {\n    async fn list_users(&mut self) -> Result<Vec<User>, WarpgateError> {\n        let db = self.db.lock().await;\n\n        let users = entities::User::Entity::find()\n            .order_by_asc(entities::User::Column::Username)\n            .all(&*db)\n            .await?;\n\n        let users: Result<Vec<User>, _> = users.into_iter().map(|t| t.try_into()).collect();\n\n        users\n    }\n\n    async fn list_targets(&mut self) -> Result<Vec<Target>, WarpgateError> {\n        let db = self.db.lock().await;\n\n        let targets = entities::Target::Entity::find()\n            .order_by_asc(entities::Target::Column::Name)\n            .all(&*db)\n            .await?;\n\n        let targets: Result<Vec<Target>, _> = targets.into_iter().map(|t| t.try_into()).collect();\n\n        Ok(targets?)\n    }\n\n    async fn get_credential_policy(\n        &mut self,\n        username: &str,\n        supported_credential_types: &[CredentialKind],\n    ) -> Result<Option<Box<dyn CredentialPolicy + Sync + Send>>, WarpgateError> {\n        let db = self.db.lock().await;\n\n        let user_model = entities::User::Entity::find()\n            .filter(entities::User::Column::Username.eq(username))\n            .one(&*db)\n            .await?;\n\n        let Some(user_model) = user_model else {\n            error!(\"Selected user not found: {}\", username);\n            return Ok(None);\n        };\n\n        let user = user_model.load_details(&db).await?;\n\n        let mut available_credential_types = user\n            .credentials\n            .iter()\n            .map(|x| x.kind())\n            .collect::<HashSet<_>>();\n        available_credential_types.insert(CredentialKind::WebUserApproval);\n\n        let supported_credential_types = supported_credential_types\n            .iter()\n            .copied()\n            .collect::<HashSet<_>>()\n            .intersection(&available_credential_types)\n            .copied()\n            .collect::<HashSet<_>>();\n\n        // \"Any single credential\" policy should not include WebUserApproval\n        // if other authentication methods are available because it could lead to user confusion\n        let default_policy = Box::new(AnySingleCredentialPolicy {\n            supported_credential_types: if supported_credential_types.len() > 1 {\n                supported_credential_types\n                    .iter()\n                    .cloned()\n                    .filter(|x| x != &CredentialKind::WebUserApproval)\n                    .collect()\n            } else {\n                supported_credential_types.clone()\n            },\n        }) as Box<dyn CredentialPolicy + Sync + Send>;\n\n        if let Some(req) = user.credential_policy.clone() {\n            let mut policy = PerProtocolCredentialPolicy {\n                default: default_policy,\n                protocols: HashMap::new(),\n            };\n\n            if let Some(p) = req.http {\n                policy.protocols.insert(\n                    \"HTTP\",\n                    Box::new(AllCredentialsPolicy {\n                        supported_credential_types: supported_credential_types.clone(),\n                        required_credential_types: p.into_iter().collect(),\n                    }),\n                );\n            }\n            if let Some(p) = req.mysql {\n                policy.protocols.insert(\n                    \"MySQL\",\n                    Box::new(AllCredentialsPolicy {\n                        supported_credential_types: supported_credential_types.clone(),\n                        required_credential_types: p.into_iter().collect(),\n                    }),\n                );\n            }\n            if let Some(p) = req.postgres {\n                policy.protocols.insert(\n                    \"PostgreSQL\",\n                    Box::new(AllCredentialsPolicy {\n                        supported_credential_types: supported_credential_types.clone(),\n                        required_credential_types: p.into_iter().collect(),\n                    }),\n                );\n            }\n            if let Some(p) = req.ssh {\n                policy.protocols.insert(\n                    \"SSH\",\n                    Box::new(AllCredentialsPolicy {\n                        supported_credential_types,\n                        required_credential_types: p.into_iter().collect(),\n                    }),\n                );\n            }\n\n            Ok(Some(\n                Box::new(policy) as Box<dyn CredentialPolicy + Sync + Send>\n            ))\n        } else {\n            Ok(Some(default_policy))\n        }\n    }\n\n    async fn username_for_sso_credential(\n        &mut self,\n        client_credential: &AuthCredential,\n        preferred_username: Option<String>,\n        sso_config: SsoProviderConfig,\n    ) -> Result<Option<String>, WarpgateError> {\n        let db = self.db.lock().await;\n\n        let AuthCredential::Sso {\n            provider: client_provider,\n            email: client_email,\n        } = client_credential\n        else {\n            return Ok(None);\n        };\n\n        let cred = entities::SsoCredential::Entity::find()\n            .filter(\n                entities::SsoCredential::Column::Email.eq(client_email).and(\n                    entities::SsoCredential::Column::Provider\n                        .eq(client_provider)\n                        .or(entities::SsoCredential::Column::Provider.is_null()),\n                ),\n            )\n            .one(&*db)\n            .await?;\n\n        if let Some(cred) = cred {\n            let user = cred.find_related(entities::User::Entity).one(&*db).await?;\n\n            if let Some(user) = user {\n                return Ok(Some(user.username.clone()));\n            }\n        }\n\n        if sso_config.auto_create_users {\n            let Some(preferred_username) = preferred_username else {\n                error!(\"The OIDC server did not provide a preferred_username claim for this user\");\n                return Ok(None);\n            };\n            return self\n                .maybe_autocreate_sso_user(\n                    &db,\n                    UserSsoCredential {\n                        email: client_email.clone(),\n                        provider: Some(client_provider.clone()),\n                    },\n                    preferred_username,\n                    sso_config.default_credential_policy.clone(),\n                )\n                .await;\n        }\n\n        Ok(None)\n    }\n\n    async fn validate_credential(\n        &mut self,\n        username: &str,\n        client_credential: &AuthCredential,\n    ) -> Result<bool, WarpgateError> {\n        let db = self.db.lock().await;\n\n        let user_model = entities::User::Entity::find()\n            .filter(entities::User::Column::Username.eq(username))\n            .one(&*db)\n            .await?;\n\n        let Some(user_model) = user_model else {\n            error!(\"Selected user not found: {}\", username);\n            return Ok(false);\n        };\n\n        // Sync SSH keys from LDAP if user is linked\n        if matches!(client_credential, AuthCredential::PublicKey { .. }) {\n            if let (Some(ldap_server_id), Some(ldap_object_uuid)) =\n                (user_model.ldap_server_id, &user_model.ldap_object_uuid)\n            {\n                if let Err(e) = self\n                    .sync_ldap_ssh_keys(&db, user_model.id, ldap_server_id, ldap_object_uuid)\n                    .await\n                {\n                    warn!(\n                        \"Failed to sync SSH keys from LDAP for user {}: {}\",\n                        username, e\n                    );\n                }\n            }\n        }\n\n        let user_details = user_model.load_details(&db).await?;\n\n        match client_credential {\n            AuthCredential::PublicKey {\n                kind,\n                public_key_bytes,\n            } => {\n                let base64_bytes = BASE64.encode(public_key_bytes);\n                let openssh_public_key = format!(\"{kind} {base64_bytes}\");\n                debug!(\n                    username = &user_details.username[..],\n                    \"Client key: {}\", openssh_public_key\n                );\n\n                Ok(user_details\n                    .credentials\n                    .iter()\n                    .any(|credential| match credential {\n                        UserAuthCredential::PublicKey(UserPublicKeyCredential {\n                            key: ref user_key,\n                        }) => &openssh_public_key == user_key.expose_secret(),\n                        _ => false,\n                    }))\n            }\n            AuthCredential::Password(client_password) => {\n                Ok(user_details\n                    .credentials\n                    .iter()\n                    .any(|credential| match credential {\n                        UserAuthCredential::Password(UserPasswordCredential {\n                            hash: ref user_password_hash,\n                        }) => verify_password_hash(\n                            client_password.expose_secret(),\n                            user_password_hash.expose_secret(),\n                        )\n                        .unwrap_or_else(|e| {\n                            error!(\n                                username = &user_details.username[..],\n                                \"Error verifying password hash: {}\", e\n                            );\n                            false\n                        }),\n                        _ => false,\n                    }))\n            }\n            AuthCredential::Otp(client_otp) => {\n                Ok(user_details\n                    .credentials\n                    .iter()\n                    .any(|credential| match credential {\n                        UserAuthCredential::Totp(UserTotpCredential {\n                            key: ref user_otp_key,\n                        }) => verify_totp(client_otp.expose_secret(), user_otp_key),\n                        _ => false,\n                    }))\n            }\n            AuthCredential::Sso {\n                provider: client_provider,\n                email: client_email,\n            } => {\n                for credential in user_details.credentials.iter() {\n                    if let UserAuthCredential::Sso(UserSsoCredential {\n                        ref provider,\n                        ref email,\n                    }) = credential\n                    {\n                        if provider.as_ref().unwrap_or(client_provider) == client_provider\n                            && email == client_email\n                        {\n                            return Ok(true);\n                        }\n                    }\n                }\n                Ok(false)\n            }\n            _ => Err(WarpgateError::InvalidCredentialType),\n        }\n    }\n\n    async fn authorize_target(\n        &mut self,\n        username: &str,\n        target_name: &str,\n    ) -> Result<bool, WarpgateError> {\n        let db = self.db.lock().await;\n\n        let target_model = entities::Target::Entity::find()\n            .filter(entities::Target::Column::Name.eq(target_name))\n            .one(&*db)\n            .await?;\n\n        let user_model = entities::User::Entity::find()\n            .filter(entities::User::Column::Username.eq(username))\n            .one(&*db)\n            .await?;\n\n        let Some(user_model) = user_model else {\n            error!(\"Selected user not found: {}\", username);\n            return Ok(false);\n        };\n\n        let Some(target_model) = target_model else {\n            warn!(\"Selected target not found: {}\", target_name);\n            return Ok(false);\n        };\n\n        let target_roles: HashSet<String> = target_model\n            .find_related(entities::Role::Entity)\n            .all(&*db)\n            .await?\n            .into_iter()\n            .map(Into::<Role>::into)\n            .map(|x| x.name)\n            .collect();\n\n        let user_roles: HashSet<String> = user_model\n            .find_related(entities::Role::Entity)\n            .all(&*db)\n            .await?\n            .into_iter()\n            .map(Into::<Role>::into)\n            .map(|x| x.name)\n            .collect();\n\n        let intersect = user_roles.intersection(&target_roles).count() > 0;\n\n        Ok(intersect)\n    }\n\n    async fn apply_sso_role_mappings(\n        &mut self,\n        username: &str,\n        managed_role_names: Option<Vec<String>>,\n        assigned_role_names: Vec<String>,\n    ) -> Result<(), WarpgateError> {\n        let db = self.db.lock().await;\n\n        let user = entities::User::Entity::find()\n            .filter(entities::User::Column::Username.eq(username))\n            .one(&*db)\n            .await?\n            .ok_or_else(|| WarpgateError::UserNotFound(username.into()))?;\n\n        let managed_role_names = match managed_role_names {\n            Some(x) => x,\n            None => entities::Role::Entity::find()\n                .all(&*db)\n                .await?\n                .into_iter()\n                .map(|x| x.name)\n                .collect(),\n        };\n\n        for role_name in managed_role_names.into_iter() {\n            let Some(role) = entities::Role::Entity::find()\n                .filter(entities::Role::Column::Name.eq(role_name.clone()))\n                .one(&*db)\n                .await?\n            else {\n                warn!(\"SSO role mapping references non-existent role {role_name:?}, skipping\");\n                continue;\n            };\n\n            let assignment = entities::UserRoleAssignment::Entity::find()\n                .filter(entities::UserRoleAssignment::Column::UserId.eq(user.id))\n                .filter(entities::UserRoleAssignment::Column::RoleId.eq(role.id))\n                .one(&*db)\n                .await?;\n\n            match (assignment, assigned_role_names.contains(&role_name)) {\n                (None, true) => {\n                    info!(\"Adding role {role_name} for user {username} (from SSO)\");\n                    let values = entities::UserRoleAssignment::ActiveModel {\n                        user_id: Set(user.id),\n                        role_id: Set(role.id),\n                        ..Default::default()\n                    };\n\n                    values.insert(&*db).await?;\n                }\n                (Some(assignment), false) => {\n                    info!(\"Removing role {role_name} for user {username} (from SSO)\");\n                    assignment.delete(&*db).await?;\n                }\n                _ => (),\n            }\n        }\n\n        Ok(())\n    }\n\n    async fn apply_sso_admin_role_mappings(\n        &mut self,\n        username: &str,\n        managed_admin_role_names: Option<Vec<String>>,\n        assigned_admin_role_names: Vec<String>,\n    ) -> Result<(), WarpgateError> {\n        let db = self.db.lock().await;\n\n        let user = entities::User::Entity::find()\n            .filter(entities::User::Column::Username.eq(username))\n            .one(&*db)\n            .await?\n            .ok_or_else(|| WarpgateError::UserNotFound(username.into()))?;\n\n        let managed_admin_role_names = match managed_admin_role_names {\n            Some(x) => x,\n            None => entities::AdminRole::Entity::find()\n                .all(&*db)\n                .await?\n                .into_iter()\n                .map(|x| x.name)\n                .collect(),\n        };\n\n        for role_name in managed_admin_role_names.into_iter() {\n            let role = entities::AdminRole::Entity::find()\n                .filter(entities::AdminRole::Column::Name.eq(role_name.clone()))\n                .one(&*db)\n                .await?\n                .ok_or_else(|| WarpgateError::RoleNotFound(role_name.clone()))?;\n\n            let assignment = entities::UserAdminRoleAssignment::Entity::find()\n                .filter(entities::UserAdminRoleAssignment::Column::UserId.eq(user.id))\n                .filter(entities::UserAdminRoleAssignment::Column::AdminRoleId.eq(role.id))\n                .one(&*db)\n                .await?;\n\n            match (assignment, assigned_admin_role_names.contains(&role_name)) {\n                (None, true) => {\n                    info!(\"Adding admin role {role_name} for user {username} (from SSO)\");\n                    let values = entities::UserAdminRoleAssignment::ActiveModel {\n                        user_id: Set(user.id),\n                        admin_role_id: Set(role.id),\n                        ..Default::default()\n                    };\n\n                    values.insert(&*db).await?;\n                }\n                (Some(assignment), false) => {\n                    info!(\"Removing admin role {role_name} for user {username} (from SSO)\");\n                    assignment.delete(&*db).await?;\n                }\n                _ => (),\n            }\n        }\n\n        Ok(())\n    }\n\n    async fn update_public_key_last_used(\n        &self,\n        credential: Option<AuthCredential>,\n    ) -> Result<(), WarpgateError> {\n        let db = self.db.lock().await;\n\n        let Some(AuthCredential::PublicKey {\n            kind,\n            public_key_bytes,\n        }) = credential\n        else {\n            error!(\"Invalid or missing public key credential\");\n            return Err(WarpgateError::InvalidCredentialType);\n        };\n\n        // Encode public key and match it against the database\n        let base64_bytes = data_encoding::BASE64.encode(&public_key_bytes);\n        let openssh_public_key = format!(\"{kind} {base64_bytes}\");\n\n        debug!(\n            \"Attempting to update last_used for public key: {}\",\n            openssh_public_key\n        );\n\n        // Find the public key credential\n        let public_key_credential = entities::PublicKeyCredential::Entity::find()\n            .filter(\n                entities::PublicKeyCredential::Column::OpensshPublicKey\n                    .eq(openssh_public_key.clone()),\n            )\n            .one(&*db)\n            .await?;\n\n        let Some(public_key_credential) = public_key_credential else {\n            warn!(\n                \"Public key not found in the database: {}\",\n                openssh_public_key\n            );\n            return Ok(()); // Gracefully return if the key is not found\n        };\n\n        // Update the `last_used` (last used) timestamp\n        let mut active_model: entities::PublicKeyCredential::ActiveModel =\n            public_key_credential.into();\n        active_model.last_used = Set(Some(Utc::now()));\n\n        active_model.update(&*db).await.map_err(|e| {\n            error!(\"Failed to update last_used for public key: {:?}\", e);\n            WarpgateError::DatabaseError(e)\n        })?;\n\n        Ok(())\n    }\n\n    async fn validate_api_token(&mut self, token: &str) -> Result<Option<User>, WarpgateError> {\n        let db = self.db.lock().await;\n        let Some(ticket) = entities::ApiToken::Entity::find()\n            .filter(\n                entities::ApiToken::Column::Secret\n                    .eq(token)\n                    .and(entities::ApiToken::Column::Expiry.gt(Utc::now())),\n            )\n            .one(&*db)\n            .await?\n        else {\n            return Ok(None);\n        };\n\n        let Some(user) = ticket\n            .find_related(entities::User::Entity)\n            .one(&*db)\n            .await?\n        else {\n            return Err(WarpgateError::InconsistentState);\n        };\n\n        Ok(Some(user.try_into()?))\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/config_providers/mod.rs",
    "content": "mod db;\nuse std::sync::Arc;\n\npub use db::DatabaseConfigProvider;\nuse enum_dispatch::enum_dispatch;\nuse sea_orm::ActiveValue::Set;\nuse sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::auth::{AuthCredential, AuthStateUserInfo, CredentialKind, CredentialPolicy};\nuse warpgate_common::{Secret, Target, User, WarpgateError};\nuse warpgate_db_entities as e;\nuse warpgate_sso::SsoProviderConfig;\n\n#[enum_dispatch]\npub enum ConfigProviderEnum {\n    Database(DatabaseConfigProvider),\n}\n\n#[enum_dispatch(ConfigProviderEnum)]\n#[allow(async_fn_in_trait)]\npub trait ConfigProvider {\n    async fn list_users(&mut self) -> Result<Vec<User>, WarpgateError>;\n\n    async fn list_targets(&mut self) -> Result<Vec<Target>, WarpgateError>;\n\n    async fn validate_credential(\n        &mut self,\n        username: &str,\n        client_credential: &AuthCredential,\n    ) -> Result<bool, WarpgateError>;\n\n    async fn username_for_sso_credential(\n        &mut self,\n        client_credential: &AuthCredential,\n        preferred_username: Option<String>,\n        sso_config: SsoProviderConfig,\n    ) -> Result<Option<String>, WarpgateError>;\n\n    async fn apply_sso_role_mappings(\n        &mut self,\n        username: &str,\n        managed_role_names: Option<Vec<String>>,\n        active_role_names: Vec<String>,\n    ) -> Result<(), WarpgateError>;\n\n    /// Similar to `apply_sso_role_mappings` but operates on *admin* roles.\n    async fn apply_sso_admin_role_mappings(\n        &mut self,\n        username: &str,\n        managed_admin_role_names: Option<Vec<String>>,\n        active_admin_role_names: Vec<String>,\n    ) -> Result<(), WarpgateError>;\n\n    async fn get_credential_policy(\n        &mut self,\n        username: &str,\n        supported_credential_types: &[CredentialKind],\n    ) -> Result<Option<Box<dyn CredentialPolicy + Sync + Send>>, WarpgateError>;\n\n    async fn authorize_target(\n        &mut self,\n        username: &str,\n        target: &str,\n    ) -> Result<bool, WarpgateError>;\n\n    async fn update_public_key_last_used(\n        &self,\n        credential: Option<AuthCredential>,\n    ) -> Result<(), WarpgateError>;\n\n    async fn validate_api_token(&mut self, token: &str) -> Result<Option<User>, WarpgateError>;\n}\n\n//TODO: move this somewhere\npub async fn authorize_ticket(\n    db: &Arc<Mutex<DatabaseConnection>>,\n    secret: &Secret<String>,\n) -> Result<Option<(e::Ticket::Model, AuthStateUserInfo)>, WarpgateError> {\n    let db = db.lock().await;\n    let ticket = {\n        e::Ticket::Entity::find()\n            .filter(e::Ticket::Column::Secret.eq(&secret.expose_secret()[..]))\n            .one(&*db)\n            .await?\n    };\n    match ticket {\n        Some(ticket) => {\n            if let Some(0) = ticket.uses_left {\n                warn!(\"Ticket is used up: {}\", &ticket.id);\n                return Ok(None);\n            }\n\n            if let Some(datetime) = ticket.expiry {\n                if datetime < chrono::Utc::now() {\n                    warn!(\"Ticket has expired: {}\", &ticket.id);\n                    return Ok(None);\n                }\n            }\n\n            // TODO maybe Ticket could properly reference the user model and then\n            // AuthStateUserInfo could be constructed from it\n            let Some(ticket_user) = e::User::Entity::find()\n                .filter(e::User::Column::Username.eq(ticket.username.clone()))\n                .one(&*db)\n                .await?\n            else {\n                return Err(WarpgateError::UserNotFound(ticket.username.clone()));\n            };\n\n            Ok(Some((ticket, (&User::try_from(ticket_user)?).into())))\n        }\n        None => {\n            warn!(\"Ticket not found: {}\", &secret.expose_secret());\n            Ok(None)\n        }\n    }\n}\n\npub async fn consume_ticket(\n    db: &Arc<Mutex<DatabaseConnection>>,\n    ticket_id: &Uuid,\n) -> Result<(), WarpgateError> {\n    let db = db.lock().await;\n    let ticket = e::Ticket::Entity::find_by_id(*ticket_id).one(&*db).await?;\n    let Some(ticket) = ticket else {\n        return Err(WarpgateError::InvalidTicket(*ticket_id));\n    };\n\n    if let Some(uses_left) = ticket.uses_left {\n        let mut model: e::Ticket::ActiveModel = ticket.into();\n        model.uses_left = Set(Some(uses_left - 1));\n        model.update(&*db).await?;\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate-core/src/consts.rs",
    "content": "pub static BUILTIN_ADMIN_ROLE_NAME: &str = \"warpgate:admin\";\npub static BUILTIN_ADMIN_USERNAME: &str = \"admin\";\n"
  },
  {
    "path": "warpgate-core/src/data.rs",
    "content": "use chrono::{DateTime, Utc};\nuse poem_openapi::Object;\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\nuse warpgate_common::{SessionId, Target};\nuse warpgate_db_entities::Session;\n\n#[derive(Serialize, Deserialize, Object)]\npub struct SessionSnapshot {\n    pub id: SessionId,\n    pub username: Option<String>,\n    pub target: Option<Target>,\n    pub started: DateTime<Utc>,\n    pub ended: Option<DateTime<Utc>>,\n    pub ticket_id: Option<Uuid>,\n    pub protocol: String,\n}\n\nimpl From<Session::Model> for SessionSnapshot {\n    fn from(model: Session::Model) -> Self {\n        Self {\n            id: model.id,\n            username: model.username,\n            target: model\n                .target_snapshot\n                .and_then(|s| serde_json::from_str(&s).ok()),\n            started: model.started,\n            ended: model.ended,\n            ticket_id: model.ticket_id,\n            protocol: model.protocol,\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/db/mod.rs",
    "content": "use std::time::Duration;\n\nuse anyhow::Result;\nuse sea_orm::sea_query::Expr;\nuse sea_orm::{\n    ConnectOptions, Database, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter,\n    TransactionTrait,\n};\nuse tracing::*;\nuse warpgate_common::helpers::fs::secure_file;\nuse warpgate_common::{GlobalParams, WarpgateConfig, WarpgateError};\nuse warpgate_db_entities::LogEntry;\nuse warpgate_db_migrations::migrate_database;\n\nuse crate::recordings::SessionRecordings;\n\npub async fn connect_to_db(\n    config: &WarpgateConfig,\n    params: &GlobalParams,\n) -> Result<DatabaseConnection> {\n    let mut url = url::Url::parse(&config.store.database_url.expose_secret()[..])?;\n    if url.scheme() == \"sqlite\" {\n        let path = url.path();\n        let mut abs_path = params.paths_relative_to().clone();\n        abs_path.push(path);\n        abs_path.push(\"db.sqlite3\");\n\n        if let Some(parent) = abs_path.parent() {\n            std::fs::create_dir_all(parent)?\n        }\n\n        url.set_path(\n            abs_path\n                .to_str()\n                .ok_or_else(|| anyhow::anyhow!(\"Failed to convert database path to string\"))?,\n        );\n\n        url.set_query(Some(\"mode=rwc\"));\n\n        let db = Database::connect(ConnectOptions::new(url.to_string())).await?;\n        db.begin().await?.commit().await?;\n\n        if params.should_secure_files() {\n            secure_file(&abs_path)?;\n        }\n    }\n\n    let mut opt = ConnectOptions::new(url.to_string());\n    opt.max_connections(100)\n        .min_connections(5)\n        .connect_timeout(Duration::from_secs(8))\n        .idle_timeout(Duration::from_secs(8))\n        .max_lifetime(Duration::from_secs(8))\n        .sqlx_logging(true);\n\n    let connection = Database::connect(opt).await?;\n\n    migrate_database(&connection).await?;\n    Ok(connection)\n}\n\npub async fn populate_db(\n    db: &mut DatabaseConnection,\n    _config: &mut WarpgateConfig,\n) -> Result<(), WarpgateError> {\n    use sea_orm::ActiveValue::Set;\n    use warpgate_db_entities::{Recording, Session};\n\n    Recording::Entity::update_many()\n        .set(Recording::ActiveModel {\n            ended: Set(Some(chrono::Utc::now())),\n            ..Default::default()\n        })\n        .filter(Expr::col(Recording::Column::Ended).is_null())\n        .exec(db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n    Session::Entity::update_many()\n        .set(Session::ActiveModel {\n            ended: Set(Some(chrono::Utc::now())),\n            ..Default::default()\n        })\n        .filter(Expr::col(Session::Column::Ended).is_null())\n        .exec(db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n    Ok(())\n}\n\npub async fn cleanup_db(\n    db: &mut DatabaseConnection,\n    recordings: &mut SessionRecordings,\n    retention: &Duration,\n) -> Result<()> {\n    use warpgate_db_entities::{Recording, Session};\n    let cutoff = chrono::Utc::now() - chrono::Duration::from_std(*retention)?;\n\n    LogEntry::Entity::delete_many()\n        .filter(Expr::col(LogEntry::Column::Timestamp).lt(cutoff))\n        .exec(db)\n        .await?;\n\n    let recordings_to_delete = Recording::Entity::find()\n        .filter(Expr::col(Session::Column::Ended).is_not_null())\n        .filter(Expr::col(Session::Column::Ended).lt(cutoff))\n        .all(db)\n        .await?;\n\n    for recording in recordings_to_delete {\n        if let Err(error) = recordings\n            .remove(&recording.session_id, &recording.name)\n            .await\n        {\n            error!(session=%recording.session_id, name=%recording.name, %error, \"Failed to remove recording\");\n        }\n        recording.delete(db).await?;\n    }\n\n    Session::Entity::delete_many()\n        .filter(Expr::col(Session::Column::Ended).is_not_null())\n        .filter(Expr::col(Session::Column::Ended).lt(cutoff))\n        .exec(db)\n        .await?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate-core/src/lib.rs",
    "content": "mod auth_state_store;\nmod config_providers;\npub mod consts;\nmod data;\npub mod db;\npub mod logging;\nmod protocols;\npub mod rate_limiting;\npub mod recordings;\nmod services;\nmod state;\npub use auth_state_store::*;\npub use config_providers::*;\npub use data::*;\npub use protocols::*;\npub use services::*;\npub use state::{SessionState, SessionStateInit, State};\n"
  },
  {
    "path": "warpgate-core/src/logging/database.rs",
    "content": "use std::sync::Arc;\n\nuse once_cell::sync::OnceCell;\nuse sea_orm::query::JsonValue;\nuse sea_orm::{ActiveModelTrait, DatabaseConnection};\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse tracing_subscriber::registry::LookupSpan;\nuse tracing_subscriber::Layer;\nuse uuid::Uuid;\nuse warpgate_db_entities::LogEntry;\n\nuse super::layer::ValuesLogLayer;\nuse super::values::SerializedRecordValues;\n\nstatic LOG_SENDER: OnceCell<tokio::sync::broadcast::Sender<LogEntry::ActiveModel>> =\n    OnceCell::new();\n\npub fn make_database_logger_layer<S>() -> impl Layer<S>\nwhere\n    S: Subscriber + for<'a> LookupSpan<'a>,\n{\n    let _ = LOG_SENDER.set(tokio::sync::broadcast::channel(1024).0);\n    ValuesLogLayer::new(|values| {\n        if let Some(sender) = LOG_SENDER.get() {\n            if let Some(entry) = values_to_log_entry_data(values) {\n                let _ = sender.send(entry);\n            }\n        }\n    })\n}\n\npub fn install_database_logger(database: Arc<Mutex<DatabaseConnection>>) {\n    tokio::spawn(async move {\n        #[allow(clippy::expect_used)]\n        let mut receiver = LOG_SENDER\n            .get()\n            .expect(\"Log sender not ready yet\")\n            .subscribe();\n        loop {\n            match receiver.recv().await {\n                Err(_) => break,\n                Ok(log_entry) => {\n                    let database = database.lock().await;\n                    if let Err(error) = log_entry.insert(&*database).await {\n                        error!(?error, \"Failed to store log entry\");\n                    }\n                }\n            }\n        }\n    });\n}\n\nfn values_to_log_entry_data(mut values: SerializedRecordValues) -> Option<LogEntry::ActiveModel> {\n    let session_id = (*values).remove(\"session\");\n    let username = (*values).remove(\"session_username\");\n    let message = (*values).remove(\"message\").unwrap_or_default();\n\n    use sea_orm::ActiveValue::Set;\n    let session_id = session_id.and_then(|x| Uuid::parse_str(&x).ok())?;\n\n    Some(LogEntry::ActiveModel {\n        id: Set(Uuid::new_v4()),\n        text: Set(message),\n        values: Set(values\n            .into_values()\n            .into_iter()\n            .map(|(k, v)| (k, JsonValue::from(v)))\n            .collect()),\n        session_id: Set(session_id),\n        username: Set(username),\n        timestamp: Set(chrono::Utc::now()),\n    })\n}\n"
  },
  {
    "path": "warpgate-core/src/logging/json_console.rs",
    "content": "use std::io::{self, Write};\n\nuse chrono::Utc;\nuse serde::Serialize;\nuse serde_json::json;\nuse tracing::{Event, Level, Subscriber};\nuse tracing_subscriber::layer::Context;\nuse tracing_subscriber::registry::LookupSpan;\nuse tracing_subscriber::Layer;\n\nuse super::values::{RecordVisitor, SerializedRecordValues};\n\n#[derive(Serialize)]\nstruct JsonLogEntry {\n    timestamp: String,\n    level: &'static str,\n    target: String,\n    message: String,\n    #[serde(flatten)]\n    fields: SerializedRecordValues,\n}\n\npub struct JsonConsoleLayer;\n\nimpl<S> Layer<S> for JsonConsoleLayer\nwhere\n    S: Subscriber + for<'a> LookupSpan<'a>,\n{\n    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {\n        // Only log warpgate events (same filter as ValuesLogLayer)\n        if !event.metadata().target().starts_with(\"warpgate\") {\n            return;\n        }\n\n        // Collect span fields (same pattern as ValuesLogLayer)\n        let mut values = SerializedRecordValues::new();\n\n        let current = ctx.current_span();\n        let parent_id = event.parent().or_else(|| current.id());\n        if let Some(parent_id) = parent_id {\n            if let Some(span) = ctx.span(parent_id) {\n                for span in span.scope().from_root() {\n                    if let Some(other_values) = span.extensions().get::<SerializedRecordValues>() {\n                        values.extend((*other_values).clone().into_iter());\n                    }\n                }\n            }\n        }\n\n        // Record event fields\n        event.record(&mut RecordVisitor::new(&mut values));\n\n        // Extract message before moving values\n        let message = values.remove(\"message\").unwrap_or_default();\n\n        let entry = JsonLogEntry {\n            timestamp: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),\n            level: level_to_str(event.metadata().level()),\n            target: event.metadata().target().to_string(),\n            message,\n            fields: values,\n        };\n\n        // Serialize with fallback on error (Requirement 3.3)\n        let json = match serde_json::to_string(&entry) {\n            Ok(j) => j,\n            Err(_) => json!({\n                \"timestamp\": entry.timestamp,\n                \"level\": entry.level,\n                \"target\": entry.target,\n                \"message\": entry.message,\n                \"_serialization_error\": true\n            })\n            .to_string(),\n        };\n\n        let _ = writeln!(io::stdout(), \"{}\", json);\n    }\n\n    fn on_new_span(\n        &self,\n        attrs: &tracing_core::span::Attributes<'_>,\n        id: &tracing_core::span::Id,\n        ctx: Context<'_, S>,\n    ) {\n        // Store span values for later collection (same as ValuesLogLayer)\n        let Some(span) = ctx.span(id) else { return };\n        if !span.metadata().target().starts_with(\"warpgate\") {\n            return;\n        }\n\n        let mut values = SerializedRecordValues::new();\n        attrs.record(&mut RecordVisitor::new(&mut values));\n        span.extensions_mut().replace(values);\n    }\n}\n\npub fn make_json_console_logger_layer<S>() -> impl Layer<S>\nwhere\n    S: Subscriber + for<'a> LookupSpan<'a>,\n{\n    JsonConsoleLayer\n}\n\nfn level_to_str(level: &Level) -> &'static str {\n    match *level {\n        Level::TRACE => \"trace\",\n        Level::DEBUG => \"debug\",\n        Level::INFO => \"info\",\n        Level::WARN => \"warn\",\n        Level::ERROR => \"error\",\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/logging/layer.rs",
    "content": "use tracing::{Event, Level, Subscriber};\nuse tracing_subscriber::layer::Context;\nuse tracing_subscriber::registry::LookupSpan;\n\nuse super::values::{RecordVisitor, SerializedRecordValues};\n\npub struct ValuesLogLayer<C>\nwhere\n    C: Fn(SerializedRecordValues),\n{\n    callback: C,\n}\n\nimpl<C> ValuesLogLayer<C>\nwhere\n    C: Fn(SerializedRecordValues),\n{\n    pub fn new(callback: C) -> Self {\n        Self { callback }\n    }\n}\n\nimpl<C, S> tracing_subscriber::Layer<S> for ValuesLogLayer<C>\nwhere\n    S: Subscriber + for<'a> LookupSpan<'a>,\n    C: Fn(SerializedRecordValues),\n    Self: 'static,\n{\n    fn on_new_span(\n        &self,\n        attrs: &tracing_core::span::Attributes<'_>,\n        id: &tracing_core::span::Id,\n        ctx: Context<'_, S>,\n    ) {\n        let Some(span) = ctx.span(id) else { return };\n        if !span.metadata().target().starts_with(\"warpgate\") {\n            return;\n        }\n\n        let mut values = SerializedRecordValues::new();\n        attrs.record(&mut RecordVisitor::new(&mut values));\n        span.extensions_mut().replace(values);\n    }\n\n    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {\n        if !event.metadata().target().starts_with(\"warpgate\") {\n            return;\n        }\n        if event.metadata().level() > &Level::INFO {\n            return;\n        }\n\n        let mut values = SerializedRecordValues::new();\n\n        let current = ctx.current_span();\n        let parent_id = event.parent().or_else(|| current.id());\n        if let Some(parent_id) = parent_id {\n            if let Some(span) = ctx.span(parent_id) {\n                for span in span.scope().from_root() {\n                    if let Some(other_values) = span.extensions().get::<SerializedRecordValues>() {\n                        values.extend((*other_values).clone().into_iter());\n                    }\n                }\n            }\n        }\n\n        event.record(&mut RecordVisitor::new(&mut values));\n\n        (self.callback)(values);\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/logging/mod.rs",
    "content": "mod database;\nmod json_console;\nmod layer;\nmod socket;\nmod values;\n\npub use database::{install_database_logger, make_database_logger_layer};\npub use json_console::make_json_console_logger_layer;\npub use socket::make_socket_logger_layer;\n"
  },
  {
    "path": "warpgate-core/src/logging/socket.rs",
    "content": "use bytes::BytesMut;\nuse chrono::format::SecondsFormat;\nuse chrono::Local;\nuse tokio::net::UnixDatagram;\nuse tracing::*;\nuse tracing_subscriber::registry::LookupSpan;\nuse tracing_subscriber::Layer;\nuse warpgate_common::WarpgateConfig;\n\nuse super::layer::ValuesLogLayer;\n\nstatic SKIP_KEY: &str = \"is_socket_logging_error\";\n\npub async fn make_socket_logger_layer<S>(config: &WarpgateConfig) -> impl Layer<S>\nwhere\n    S: Subscriber + for<'a> LookupSpan<'a>,\n{\n    let mut socket = None;\n    let socket_address = config.store.log.send_to.clone();\n    if socket_address.is_some() {\n        socket = UnixDatagram::unbound()\n            .map_err(|error| {\n                println!(\"Failed to create the log forwarding UDP socket: {error}\");\n            })\n            .ok();\n    }\n\n    let (tx, mut rx) = tokio::sync::mpsc::channel(1024);\n\n    let got_socket = socket.is_some();\n\n    let layer = ValuesLogLayer::new(move |mut values| {\n        if !got_socket || values.contains_key(&SKIP_KEY) {\n            return;\n        }\n        values.insert(\n            \"timestamp\",\n            Local::now().to_rfc3339_opts(SecondsFormat::Nanos, false),\n        );\n        let _ = tx.try_send(values);\n    });\n\n    if !got_socket {\n        return layer;\n    }\n\n    tokio::spawn(async move {\n        while let Some(values) = rx.recv().await {\n            let Some(ref socket) = socket else { return };\n            let Some(ref socket_address) = socket_address else {\n                return;\n            };\n\n            let Ok(serialized) = serde_json::to_vec(&values) else {\n                eprintln!(\"Failed to serialize log entry {values:?}\");\n                continue;\n            };\n\n            let buffer = BytesMut::from(&serialized[..]);\n            if let Err(error) = socket.send_to(buffer.as_ref(), socket_address).await {\n                error!(%error, is_socket_logging_error=true, \"Failed to forward log entry\");\n            }\n        }\n    });\n\n    layer\n}\n"
  },
  {
    "path": "warpgate-core/src/logging/values.rs",
    "content": "use std::collections::HashMap;\nuse std::fmt::Debug;\nuse std::ops::DerefMut;\n\nuse serde::Serialize;\nuse tracing::field::Visit;\nuse tracing_core::Field;\n\npub type SerializedRecordValuesInner = HashMap<&'static str, String>;\n\n#[derive(Serialize, Debug)]\npub struct SerializedRecordValues(SerializedRecordValuesInner);\n\nimpl SerializedRecordValues {\n    pub fn new() -> Self {\n        Self(HashMap::new())\n    }\n\n    pub fn into_values(self) -> SerializedRecordValuesInner {\n        self.0\n    }\n}\n\nimpl std::ops::Deref for SerializedRecordValues {\n    type Target = SerializedRecordValuesInner;\n\n    fn deref(&self) -> &Self::Target {\n        &self.0\n    }\n}\n\nimpl DerefMut for SerializedRecordValues {\n    fn deref_mut(&mut self) -> &mut Self::Target {\n        &mut self.0\n    }\n}\n\npub struct RecordVisitor<'a> {\n    values: &'a mut SerializedRecordValues,\n}\n\nimpl<'a> RecordVisitor<'a> {\n    pub fn new(values: &'a mut SerializedRecordValues) -> Self {\n        Self { values }\n    }\n}\n\nimpl Visit for RecordVisitor<'_> {\n    fn record_str(&mut self, field: &Field, value: &str) {\n        self.values.insert(field.name(), value.to_string());\n    }\n\n    fn record_debug(&mut self, field: &Field, value: &dyn Debug) {\n        self.values.insert(field.name(), format!(\"{value:?}\"));\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/protocols/handle.rs",
    "content": "use std::sync::Arc;\n\nuse sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};\nuse tokio::io::{AsyncRead, AsyncWrite};\nuse tokio::sync::Mutex;\nuse warpgate_common::auth::AuthStateUserInfo;\nuse warpgate_common::{SessionId, Target, WarpgateError};\nuse warpgate_db_entities::Session;\n\nuse crate::rate_limiting::{stack_rate_limiters, RateLimiterRegistry};\nuse crate::{SessionState, State};\n\npub trait SessionHandle {\n    fn close(&mut self);\n}\n\n#[derive(Clone)]\npub struct WarpgateServerHandle {\n    id: SessionId,\n    db: Arc<Mutex<DatabaseConnection>>,\n    state: Arc<Mutex<State>>,\n    session_state: Arc<Mutex<SessionState>>,\n    rate_limiters_registry: Arc<Mutex<RateLimiterRegistry>>,\n}\n\nimpl WarpgateServerHandle {\n    pub fn new(\n        id: SessionId,\n        db: Arc<Mutex<DatabaseConnection>>,\n        state: Arc<Mutex<State>>,\n        session_state: Arc<Mutex<SessionState>>,\n        rate_limiters_registry: Arc<Mutex<RateLimiterRegistry>>,\n    ) -> Result<Self, WarpgateError> {\n        Ok(WarpgateServerHandle {\n            id,\n            db,\n            state,\n            session_state,\n            rate_limiters_registry,\n        })\n    }\n\n    pub fn id(&self) -> SessionId {\n        self.id\n    }\n\n    pub fn session_state(&self) -> &Arc<Mutex<SessionState>> {\n        &self.session_state\n    }\n\n    pub async fn set_user_info(&self, user_info: AuthStateUserInfo) -> Result<(), WarpgateError> {\n        use sea_orm::ActiveValue::Set;\n\n        {\n            let mut state = self.session_state.lock().await;\n            state.user_info = Some(user_info.clone());\n            state.emit_change()\n        }\n\n        let db = self.db.lock().await;\n\n        Session::Entity::update_many()\n            .set(Session::ActiveModel {\n                username: Set(Some(user_info.username)),\n                ..Default::default()\n            })\n            .filter(Session::Column::Id.eq(self.id))\n            .exec(&*db)\n            .await?;\n\n        drop(db);\n\n        self.update_rate_limiters().await?;\n\n        Ok(())\n    }\n\n    pub async fn set_target(&self, target: &Target) -> Result<(), WarpgateError> {\n        use sea_orm::ActiveValue::Set;\n        {\n            let mut state = self.session_state.lock().await;\n            state.target = Some(target.clone());\n            state.emit_change()\n        }\n\n        let db = self.db.lock().await;\n\n        Session::Entity::update_many()\n            .set(Session::ActiveModel {\n                target_snapshot: Set(Some(\n                    serde_json::to_string(&target).map_err(WarpgateError::other)?,\n                )),\n                ..Default::default()\n            })\n            .filter(Session::Column::Id.eq(self.id))\n            .exec(&*db)\n            .await?;\n\n        drop(db);\n\n        self.update_rate_limiters().await?;\n\n        Ok(())\n    }\n\n    pub async fn wrap_stream(\n        &mut self,\n        stream: impl AsyncRead + AsyncWrite + Unpin + Send,\n    ) -> Result<impl AsyncRead + AsyncWrite + Unpin + Send, WarpgateError> {\n        let (stream, mut handle) = stack_rate_limiters(stream);\n        let mut ss = self.session_state.lock().await;\n        self.rate_limiters_registry\n            .lock()\n            .await\n            .update_rate_limiters(&ss, &mut handle)\n            .await?;\n        ss.rate_limiter_handles.push(handle);\n        Ok(stream)\n    }\n\n    async fn update_rate_limiters(&self) -> Result<(), WarpgateError> {\n        let mut state = self.session_state.lock().await;\n        let mut registry = self.rate_limiters_registry.lock().await;\n        registry.update_all_rate_limiters(&mut state).await?;\n        Ok(())\n    }\n}\n\nimpl Drop for WarpgateServerHandle {\n    fn drop(&mut self) {\n        let id = self.id;\n        let state = self.state.clone();\n        tokio::spawn(async move {\n            state.lock().await.remove_session(id).await;\n        });\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/protocols/mod.rs",
    "content": "use std::fmt::Debug;\nuse std::future::Future;\n\nuse anyhow::Result;\nuse warpgate_common::ListenEndpoint;\n\nmod handle;\n\npub use handle::{SessionHandle, WarpgateServerHandle};\n\n#[derive(Debug, thiserror::Error)]\npub enum TargetTestError {\n    #[error(\"unreachable\")]\n    Unreachable,\n    #[error(\"authentication failed\")]\n    AuthenticationError,\n    #[error(\"connection error: {0}\")]\n    ConnectionError(String),\n    #[error(\"misconfigured: {0}\")]\n    Misconfigured(String),\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(\"dialoguer: {0}\")]\n    Dialoguer(#[from] dialoguer::Error),\n}\n\npub trait ProtocolServer {\n    fn name(&self) -> &'static str;\n    fn run(self, address: ListenEndpoint) -> impl Future<Output = Result<()>> + Send;\n}\n"
  },
  {
    "path": "warpgate-core/src/rate_limiting/limiter.rs",
    "content": "use std::fmt::Debug;\nuse std::num::{NonZero, NonZeroU32};\n\nuse governor::clock::{Clock, QuantaClock, QuantaInstant};\nuse governor::Quota;\nuse warpgate_common::WarpgateError;\n\nuse super::shared_limiter::SharedWarpgateRateLimiter;\nuse super::{InnerRateLimiter, RateLimiterDirection};\n\npub fn new_rate_limiter(bytes_per_second: NonZeroU32) -> InnerRateLimiter {\n    let max_cells = NonZeroU32::MAX;\n    let rate_limiter =\n        InnerRateLimiter::keyed(Quota::per_second(bytes_per_second).allow_burst(max_cells));\n    // Keep the burst capacity high to allow checking in large buffers but\n    // consume (burst - per_second) cells initially to ensure that\n    // the rate limiter is in its \"normal\" state\n    #[allow(clippy::unwrap_used)] // checked\n    for key in [RateLimiterDirection::Read, RateLimiterDirection::Write] {\n        let _ = rate_limiter.check_key_n(\n            &key,\n            (u32::from(max_cells) - u32::from(bytes_per_second))\n                .try_into()\n                .unwrap(),\n        );\n    }\n    rate_limiter\n}\n\npub fn assert_valid_quota(v: u32) -> Result<NonZeroU32, WarpgateError> {\n    NonZeroU32::new(v).ok_or(WarpgateError::RateLimiterInvalidQuota(v))\n}\n\n/// Houses a replaceable shared reference to a `governor` rate limiter\n///\n/// Note: this struct cannot be publicly instantiated without being\n/// container in a `SharedWarpgateRateLimiter` because we want to prevent\n/// somebody putting it in a tokio::sync::Mutex.\n///\n/// See [super] for details.\npub struct WarpgateRateLimiter {\n    inner: Option<(InnerRateLimiter, NonZeroU32)>,\n}\n\nimpl Debug for WarpgateRateLimiter {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"WarpgateRateLimiter\")\n            .field(\"bytes_per_second\", &self.inner.as_ref().map(|x| x.1))\n            .finish()\n    }\n}\n\nimpl WarpgateRateLimiter {\n    pub fn now() -> QuantaInstant {\n        QuantaClock::default().now()\n    }\n\n    pub fn unlimited() -> SharedWarpgateRateLimiter {\n        Self { inner: None }.share()\n    }\n\n    pub fn limited(bytes_per_second: NonZeroU32) -> SharedWarpgateRateLimiter {\n        let rate_limiter = new_rate_limiter(bytes_per_second);\n        Self {\n            inner: Some((rate_limiter, bytes_per_second)),\n        }\n        .share()\n    }\n\n    #[allow(clippy::new_ret_no_self)]\n    pub fn new(bytes_per_second: Option<u32>) -> Result<SharedWarpgateRateLimiter, WarpgateError> {\n        match bytes_per_second {\n            Some(bytes) => Ok(Self::limited(assert_valid_quota(bytes)?)),\n            None => Ok(Self::unlimited()),\n        }\n    }\n\n    pub fn replace(&mut self, bytes_per_second: Option<u32>) -> Result<(), WarpgateError> {\n        match bytes_per_second {\n            None => {\n                self.inner = None;\n            }\n            Some(bytes) => {\n                let bps = assert_valid_quota(bytes)?;\n                self.inner = Some((new_rate_limiter(bps), bps))\n            }\n        };\n        Ok(())\n    }\n\n    #[must_use = \"Must use the Instant to wait\"]\n    pub fn bytes_ready_at(\n        &self,\n        direction: RateLimiterDirection,\n        bytes: usize,\n    ) -> Result<Option<QuantaInstant>, WarpgateError> {\n        let Some(ref inner) = self.inner else {\n            return Ok(None);\n        };\n        let bytes = match NonZero::new(bytes as u32) {\n            Some(bytes) => bytes,\n            None => return Ok(None),\n        };\n        match inner.0.check_key_n(&direction, bytes)? {\n            Ok(_) => Ok(None),\n            Err(e) => Ok(Some(e.earliest_possible())),\n        }\n    }\n\n    fn share(self) -> SharedWarpgateRateLimiter {\n        SharedWarpgateRateLimiter::new(self)\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/rate_limiting/mod.rs",
    "content": "//! ## Hierarchy\n//!\n//! * [WarpgateRateLimiter] - wraps a [governor::DefaultKeyedRateLimiter], allows mutating the quota and provides app specific logic. Each [WarpgateRateLimiter] houses its own unique rate limiter.\n//! * [SharedWarpgateRateLimiter] - makes [WarpgateRateLimiter] shareable and locked.\n//! * [SwappableLimiterCell] - a cell containing a [SharedWarpgateRateLimiter] reference which can be swapped out wholesale. Multiple [SwappableLimiterCell]s can reference the same rate limiter instance.\n//!\n//! ## Why are limiters locked with a [std::sync::Mutex]?\n//!\n//! The issue with that is that if a limiter in a [tokio] Mutex is then used\n//! within a `RateLimitedStream`, then the semantics of\n//! [tokio::io::split] (internal lock between read and write halves)\n//! will cause deadlock if read and write futures are interleaved and one of them has\n//! a pending wait on the async mutex.\n//!\n//! So instead we force it to always be wrapped in a [SharedWarpgateRateLimiter]\n//! with a sync mutex inside and never be used across awaits.\n\n//! ## Why different wrapper types?\n//!\n//! There are two types of live \"replacements\" going on with rate limiters:\n//! * Swapping out a limiter in a [RateLimitedStream]'s [SwappableLimiterCellHandle]\n//!   when the related entity changes, e.g. when the user logs in and now\n//!   a user limit applies to them. This is [SwappableLimiterCellHandle::replace]\n//! * Replacing the limit inside a concrete [WarpgateRateLimiter] when the limit\n//!   is changed by the admin. This is [WarpgateRateLimiter::replace]\n\nmod limiter;\nmod registry;\nmod shared_limiter;\nmod stack;\nmod stream;\nmod swappable_cell;\n\nuse governor::DefaultKeyedRateLimiter;\npub use limiter::WarpgateRateLimiter;\npub use registry::RateLimiterRegistry;\npub use shared_limiter::{SharedWarpgateRateLimiter, SharedWarpgateRateLimiterGuard};\npub use stack::{stack_rate_limiters, RateLimiterStackHandle};\npub use stream::RateLimitedStream;\npub use swappable_cell::{SwappableLimiterCell, SwappableLimiterCellHandle};\n\n#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]\npub enum RateLimiterDirection {\n    Read,\n    Write,\n}\n\npub type InnerRateLimiter = DefaultKeyedRateLimiter<RateLimiterDirection>;\n"
  },
  {
    "path": "warpgate-core/src/rate_limiting/registry.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::Arc;\n\nuse sea_orm::{DatabaseConnection, EntityTrait};\nuse tokio::sync::Mutex;\nuse tracing::debug;\nuse uuid::Uuid;\nuse warpgate_common::WarpgateError;\nuse warpgate_db_entities::{Parameters, Target, User};\n\nuse super::shared_limiter::SharedWarpgateRateLimiter;\nuse super::{RateLimiterStackHandle, WarpgateRateLimiter};\nuse crate::{SessionState, State};\n\npub struct RateLimiterRegistry {\n    db: Arc<Mutex<DatabaseConnection>>,\n    global_rate_limiter: SharedWarpgateRateLimiter,\n    user_rate_limiters: HashMap<Uuid, SharedWarpgateRateLimiter>,\n    target_rate_limiters: HashMap<Uuid, SharedWarpgateRateLimiter>,\n}\n\nimpl RateLimiterRegistry {\n    pub fn new(db: Arc<Mutex<DatabaseConnection>>) -> Self {\n        Self {\n            db,\n            global_rate_limiter: WarpgateRateLimiter::unlimited(),\n            user_rate_limiters: HashMap::new(),\n            target_rate_limiters: HashMap::new(),\n        }\n    }\n\n    // TODO granular refresh\n    pub async fn refresh(&mut self) -> Result<(), WarpgateError> {\n        let global_quota = self.global_quota().await?;\n        self.global_rate_limiter.lock().replace(global_quota)?;\n\n        for (user_id, limiter) in self.user_rate_limiters.iter() {\n            let quota = self.quota_for_user(user_id).await?;\n            limiter.lock().replace(quota)?;\n        }\n        for (target_id, limiter) in self.target_rate_limiters.iter() {\n            let quota = self.quota_for_target(target_id).await?;\n            limiter.lock().replace(quota)?;\n        }\n        Ok(())\n    }\n\n    pub fn global(&self) -> SharedWarpgateRateLimiter {\n        self.global_rate_limiter.clone()\n    }\n\n    async fn global_quota(&mut self) -> Result<Option<u32>, WarpgateError> {\n        let db = self.db.lock().await;\n        let parameters = Parameters::Entity::get(&db).await?;\n        Ok(parameters.rate_limit_bytes_per_second.map(|x| x as u32))\n    }\n\n    pub async fn user(\n        &mut self,\n        user_id: &Uuid,\n    ) -> Result<SharedWarpgateRateLimiter, WarpgateError> {\n        if !self.user_rate_limiters.contains_key(user_id) {\n            let quota = self.quota_for_user(user_id).await?;\n            let rate_limiter = WarpgateRateLimiter::new(quota)?;\n            self.user_rate_limiters.insert(*user_id, rate_limiter);\n        }\n        #[allow(clippy::unwrap_used, reason = \"just inserted\")]\n        Ok(self.user_rate_limiters.get(user_id).unwrap().clone())\n    }\n\n    async fn quota_for_user(&self, user_id: &Uuid) -> Result<Option<u32>, WarpgateError> {\n        let db = self.db.lock().await;\n        let user = User::Entity::find_by_id(*user_id).one(&*db).await?;\n        Ok(user\n            .and_then(|u| u.rate_limit_bytes_per_second)\n            .map(|r| r as u32))\n    }\n\n    pub async fn target(\n        &mut self,\n        target_id: &Uuid,\n    ) -> Result<SharedWarpgateRateLimiter, WarpgateError> {\n        if !self.target_rate_limiters.contains_key(target_id) {\n            let quota = self.quota_for_target(target_id).await?;\n            let rate_limiter = WarpgateRateLimiter::new(quota)?;\n            self.target_rate_limiters.insert(*target_id, rate_limiter);\n        }\n        #[allow(clippy::unwrap_used, reason = \"just inserted\")]\n        Ok(self.target_rate_limiters.get(target_id).unwrap().clone())\n    }\n\n    async fn quota_for_target(&self, target_id: &Uuid) -> Result<Option<u32>, WarpgateError> {\n        let db = self.db.lock().await;\n        let target = Target::Entity::find_by_id(*target_id).one(&*db).await?;\n        Ok(target\n            .and_then(|t| t.rate_limit_bytes_per_second)\n            .map(|r| r as u32))\n    }\n\n    pub async fn update_all_rate_limiters(\n        &mut self,\n        state: &mut SessionState,\n    ) -> Result<(), WarpgateError> {\n        async fn inner(\n            this: &mut RateLimiterRegistry,\n            state: &mut SessionState,\n            handles: &mut [RateLimiterStackHandle],\n        ) -> Result<(), WarpgateError> {\n            for handle in handles.iter_mut() {\n                this.update_rate_limiters(state, handle).await?;\n            }\n            Ok(())\n        }\n\n        let mut handles = std::mem::take(&mut state.rate_limiter_handles);\n        // Defer result handling so that we can put back the handles\n        let result = inner(self, state, &mut handles).await;\n        state.rate_limiter_handles = handles;\n        result\n    }\n\n    pub async fn update_rate_limiters(\n        &mut self,\n        state: &SessionState,\n        handle: &mut RateLimiterStackHandle,\n    ) -> Result<(), WarpgateError> {\n        if let Some(user_info) = &state.user_info {\n            let user_limiter = self.user(&user_info.id).await?;\n            debug!(\"Setting user rate limit {user_limiter:?}\");\n            handle.user.replace(Some(user_limiter));\n        } else {\n            handle.user.replace(None);\n        }\n\n        if let Some(target) = &state.target {\n            let target_limiter = self.target(&target.id).await?;\n            debug!(\"Setting user rate limit {target_limiter:?}\");\n            handle.target.replace(Some(target_limiter));\n        } else {\n            handle.target.replace(None);\n        }\n\n        let global = self.global();\n        debug!(\"Setting global rate limit {global:?}\");\n        handle.global.replace(Some(global));\n\n        Ok(())\n    }\n\n    /// Force refresh all rate limiters in all sessions\n    pub async fn apply_new_rate_limits(&mut self, state: &mut State) -> Result<(), WarpgateError> {\n        // Refresh the global rate limiter\n        self.refresh().await?;\n\n        // Update all session rate limiters\n        for session_state in state.sessions.values() {\n            let mut session_state = session_state.lock().await;\n            self.update_all_rate_limiters(&mut session_state).await?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/rate_limiting/shared_limiter.rs",
    "content": "use std::fmt::Debug;\nuse std::ops::{Deref, DerefMut};\nuse std::sync::Arc;\n\nuse super::WarpgateRateLimiter;\n\n#[derive(Clone, Debug)]\npub struct SharedWarpgateRateLimiter {\n    inner: Arc<std::sync::Mutex<WarpgateRateLimiter>>,\n}\n\nimpl SharedWarpgateRateLimiter {\n    pub(crate) fn new(limiter: WarpgateRateLimiter) -> Self {\n        Self {\n            inner: Arc::new(std::sync::Mutex::new(limiter)),\n        }\n    }\n\n    pub fn lock(&self) -> SharedWarpgateRateLimiterGuard<'_> {\n        #[allow(clippy::unwrap_used, reason = \"panic on poison\")]\n        SharedWarpgateRateLimiterGuard::new(self.inner.lock().unwrap())\n    }\n}\n\n/// Encapsulates a shared reference to a `WarpgateRateLimiter` in a mutex\n/// and prevents locks from being sent across awaits\npub struct SharedWarpgateRateLimiterGuard<'a> {\n    inner: std::sync::MutexGuard<'a, WarpgateRateLimiter>,\n    // prevent locks across awaits\n    _non_sendable: std::marker::PhantomData<*const ()>,\n}\n\nimpl<'a> SharedWarpgateRateLimiterGuard<'a> {\n    pub fn new(inner: std::sync::MutexGuard<'a, WarpgateRateLimiter>) -> Self {\n        Self {\n            inner,\n            _non_sendable: std::marker::PhantomData,\n        }\n    }\n}\n\nimpl Deref for SharedWarpgateRateLimiterGuard<'_> {\n    type Target = WarpgateRateLimiter;\n\n    fn deref(&self) -> &Self::Target {\n        &self.inner\n    }\n}\n\nimpl DerefMut for SharedWarpgateRateLimiterGuard<'_> {\n    fn deref_mut(&mut self) -> &mut Self::Target {\n        &mut self.inner\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/rate_limiting/stack.rs",
    "content": "use tokio::io::{AsyncRead, AsyncWrite};\n\nuse super::swappable_cell::SwappableLimiterCellHandle;\nuse super::RateLimitedStream;\n\n/// Three [RateLimitedStream]s in a trenchcoat, one with a global limiter,\n/// one with a user limiter and one with a target limiter, wrapping each other.\n/// The handle lets you swap out the limiters in each of them remotely.\n/// Created via [stack_rate_limiters].\npub struct RateLimiterStackHandle {\n    pub user: SwappableLimiterCellHandle,\n    pub target: SwappableLimiterCellHandle,\n    pub global: SwappableLimiterCellHandle,\n}\n\npub fn stack_rate_limiters<S: AsyncRead + AsyncWrite + Unpin + Send>(\n    stream: S,\n) -> (\n    impl AsyncRead + AsyncWrite + Unpin + Send,\n    RateLimiterStackHandle,\n) {\n    let (stream, global_handle) = RateLimitedStream::new_unlimited(stream);\n    let (stream, user_handle) = RateLimitedStream::new_unlimited(stream);\n    let (stream, target_handle) = RateLimitedStream::new_unlimited(stream);\n\n    (\n        stream,\n        RateLimiterStackHandle {\n            user: user_handle,\n            target: target_handle,\n            global: global_handle,\n        },\n    )\n}\n"
  },
  {
    "path": "warpgate-core/src/rate_limiting/stream.rs",
    "content": "use std::future::Future;\nuse std::io;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\nuse std::time::{Duration, Instant};\n\nuse futures::FutureExt;\nuse governor::clock::Reference;\nuse governor::Jitter;\nuse tokio::io::{AsyncRead, AsyncWrite, ReadBuf};\n\nuse crate::rate_limiting::{\n    RateLimiterDirection, SwappableLimiterCell, SwappableLimiterCellHandle, WarpgateRateLimiter,\n};\n\ntype WaitFuture = Pin<Box<dyn Future<Output = ()> + Send>>;\n\nenum PendingWaitState {\n    /// No active wait -> may start new wait on poll.\n    /// The usize is the number of bytes processed in the last operation.\n    Empty(usize),\n    /// Active wait is pending -> polls the wait\n    Waiting(usize, WaitFuture),\n    /// Active wait has ended -> polls as Ready\n    Ready,\n}\n\n/// A Future-like container that can polled for I/O rate limiting.\n/// Remembers the state of the wait and can be reset.\n/// Call `poll_rate_limit` with IO operation params to wait.\n/// Once a single wait is done, will always poll Ready until `.reset()` is called.\nstruct PendingWait {\n    state: PendingWaitState,\n    direction: RateLimiterDirection,\n}\n\nimpl PendingWait {\n    pub fn new(direction: RateLimiterDirection) -> Self {\n        Self {\n            state: PendingWaitState::Empty(0),\n            direction,\n        }\n    }\n\n    fn poll_rate_limit(\n        &mut self,\n        limiter: &mut SwappableLimiterCell,\n        cx: &mut Context<'_>,\n    ) -> Poll<Result<(), io::Error>> {\n        // The loop runs at most 2x\n        loop {\n            if let PendingWaitState::Empty(len) = self.state {\n                // Check if we need to wait\n                match limiter.bytes_ready_at(self.direction, len) {\n                    Ok(None) => {\n                        self.state = PendingWaitState::Ready;\n                    }\n                    Ok(Some(at)) => {\n                        let now_quanta = WarpgateRateLimiter::now();\n                        let now_tokio = Instant::now();\n\n                        let delta = Duration::from(at.duration_since(now_quanta));\n                        let a = 10; // percent\n                        let tokio_deadline =\n                            Jitter::new(delta * (100 - a) / 100, delta * a * 2 / 100) + now_tokio;\n\n                        let fut = tokio::time::sleep_until(tokio_deadline.into()).boxed();\n\n                        self.state = PendingWaitState::Waiting(len, fut);\n                    }\n                    Err(e) => {\n                        self.state = PendingWaitState::Empty(0);\n                        return Poll::Ready(Err(io::Error::other(e.to_string())));\n                    }\n                };\n            };\n            match self.state {\n                PendingWaitState::Empty(_) => unreachable!(),\n                PendingWaitState::Waiting(len, ref mut fut) => match fut.as_mut().poll(cx) {\n                    Poll::Pending => return Poll::Pending,\n                    Poll::Ready(_) => {\n                        // !!\n                        // Since we did not consume any cells\n                        // we need to try again and maybe wait\n                        // again if there aren't enough available yet.\n                        self.state = PendingWaitState::Empty(len);\n                        continue;\n                    }\n                },\n                PendingWaitState::Ready => {}\n            }\n            break;\n        }\n\n        Poll::Ready(Ok(()))\n    }\n\n    pub fn reset(&mut self, last_chunk_size: usize) {\n        self.state = PendingWaitState::Empty(last_chunk_size);\n    }\n}\n\n// Deadlock alert: the same stream's `read_wait` internal future can become\n// paused while something else is waiting on `write_wait`, leading to a deadlock\n// since both are accessing the SwappableLimiterCell's internal lock.\n\npub struct RateLimitedStream<T> {\n    inner: T,\n    limiter: SwappableLimiterCell,\n    read_wait: PendingWait,\n    write_wait: PendingWait,\n}\n\nimpl<T: Send> RateLimitedStream<T> {\n    pub fn new(inner: T, limiter: SwappableLimiterCell) -> Self {\n        Self {\n            inner,\n            limiter,\n            read_wait: PendingWait::new(RateLimiterDirection::Read),\n            write_wait: PendingWait::new(RateLimiterDirection::Write),\n        }\n    }\n\n    pub fn new_unlimited(inner: T) -> (Self, SwappableLimiterCellHandle) {\n        let limiter = SwappableLimiterCell::empty();\n        let handle = limiter.handle();\n        (\n            Self {\n                inner,\n                limiter,\n                read_wait: PendingWait::new(RateLimiterDirection::Read),\n                write_wait: PendingWait::new(RateLimiterDirection::Write),\n            },\n            handle,\n        )\n    }\n}\n\nimpl<T: AsyncRead + Unpin + Send> RateLimitedStream<T> {\n    fn poll_read_nowait(\n        &mut self,\n        cx: &mut Context<'_>,\n        buf: &mut ReadBuf<'_>,\n    ) -> Poll<Result<(), io::Error>> {\n        let prev_remaining = buf.remaining();\n        let ret = Pin::new(&mut self.inner).poll_read(cx, buf);\n        if ret.is_ready() {\n            let read = prev_remaining - buf.remaining();\n            // Read completed, reset waiter\n            self.read_wait.reset(read);\n        }\n        ret\n    }\n}\n\nimpl<T: AsyncWrite + Unpin + Send> RateLimitedStream<T> {\n    fn poll_write_nowait(\n        &mut self,\n        cx: &mut Context<'_>,\n        data: &[u8],\n    ) -> Poll<Result<usize, io::Error>> {\n        let ret = Pin::new(&mut self.inner).poll_write(cx, data);\n        if let Poll::Ready(result) = &ret {\n            // Write completed, reset waiter\n            self.write_wait.reset(match result {\n                Ok(bytes_written) => *bytes_written,\n                Err(_) => 0,\n            });\n        }\n        ret\n    }\n}\n\nimpl<T: AsyncRead + Unpin + Send> AsyncRead for RateLimitedStream<T> {\n    fn poll_read(\n        self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &mut ReadBuf<'_>,\n    ) -> Poll<Result<(), io::Error>> {\n        let this = self.get_mut();\n        let to_read = buf.remaining();\n        if to_read == 0 {\n            // ready check\n            return Pin::new(&mut this.inner).poll_read(cx, buf);\n        }\n\n        match this.read_wait.poll_rate_limit(&mut this.limiter, cx) {\n            Poll::Ready(Ok(())) => this.poll_read_nowait(cx, buf),\n            x => x,\n        }\n    }\n}\n\nimpl<T: AsyncWrite + Unpin + Send> AsyncWrite for RateLimitedStream<T> {\n    fn poll_write(\n        self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        data: &[u8],\n    ) -> Poll<Result<usize, io::Error>> {\n        let this = self.get_mut();\n\n        if data.is_empty() {\n            // A ready check from tokio\n            return Pin::new(&mut this.inner).poll_write(cx, data);\n        }\n\n        match this.write_wait.poll_rate_limit(&mut this.limiter, cx) {\n            Poll::Ready(Ok(())) => this.poll_write_nowait(cx, data),\n            Poll::Ready(Err(e)) => Poll::Ready(Err(e)),\n            Poll::Pending => Poll::Pending,\n        }\n    }\n\n    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {\n        Pin::new(&mut self.get_mut().inner).poll_flush(cx)\n    }\n\n    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {\n        Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/rate_limiting/swappable_cell.rs",
    "content": "use governor::clock::QuantaInstant;\nuse tokio::sync::watch;\nuse warpgate_common::WarpgateError;\n\nuse super::shared_limiter::SharedWarpgateRateLimiter;\nuse super::RateLimiterDirection;\n\npub struct SwappableLimiterCellHandle {\n    sender: watch::Sender<Option<SharedWarpgateRateLimiter>>,\n}\n\nimpl SwappableLimiterCellHandle {\n    pub fn replace(&self, limiter: Option<SharedWarpgateRateLimiter>) {\n        let _ = self.sender.send(limiter);\n    }\n}\n\n/// Houses a replaceable shared reference to a `WarpgateRateLimiter` rate limiter.\n/// Cloning the cell will provide a copy that is synchronized with the original\n#[derive(Clone)]\npub struct SwappableLimiterCell {\n    inner: Option<SharedWarpgateRateLimiter>,\n    receiver: watch::Receiver<Option<SharedWarpgateRateLimiter>>,\n    sender: watch::Sender<Option<SharedWarpgateRateLimiter>>,\n}\n\nimpl SwappableLimiterCell {\n    pub fn empty() -> Self {\n        let (sender, receiver) = watch::channel(None);\n        Self {\n            inner: None,\n            receiver,\n            sender,\n        }\n    }\n\n    pub fn handle(&self) -> SwappableLimiterCellHandle {\n        SwappableLimiterCellHandle {\n            sender: self.sender.clone(),\n        }\n    }\n\n    fn _maybe_update(&mut self) {\n        let _ref = self.receiver.borrow_and_update();\n        if _ref.has_changed() {\n            self.inner = _ref.as_ref().cloned();\n        }\n    }\n\n    #[must_use = \"Must use the Instant to wait\"]\n    pub fn bytes_ready_at(\n        &mut self,\n        direction: RateLimiterDirection,\n        bytes: usize,\n    ) -> Result<Option<QuantaInstant>, WarpgateError> {\n        self._maybe_update();\n        let Some(ref rate_limiter) = self.inner else {\n            return Ok(None);\n        };\n        rate_limiter.lock().bytes_ready_at(direction, bytes)\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/recordings/mod.rs",
    "content": "use std::collections::HashMap;\nuse std::fmt::Debug;\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\n\nuse bytes::Bytes;\nuse sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};\nuse serde::Serialize;\nuse tokio::sync::{broadcast, Mutex};\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::helpers::fs::secure_directory;\nuse warpgate_common::{GlobalParams, RecordingsConfig, SessionId, WarpgateConfig};\nuse warpgate_db_entities::Recording::{self, RecordingKind};\nmod terminal;\nmod traffic;\nmod writer;\npub use terminal::*;\npub use traffic::*;\npub use writer::RecordingWriter;\n\n#[derive(thiserror::Error, Debug)]\npub enum Error {\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n\n    #[error(\"Database: {0}\")]\n    Database(#[from] sea_orm::DbErr),\n\n    #[error(\"Failed to serialize a recording item: {0}\")]\n    Serialization(#[from] serde_json::Error),\n\n    #[error(\"Writer is closed\")]\n    Closed,\n\n    #[error(\"Disabled\")]\n    Disabled,\n\n    #[error(\"Invalid recording path\")]\n    InvalidPath,\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n\npub trait Recorder {\n    fn kind() -> RecordingKind;\n    fn new(writer: RecordingWriter) -> Self;\n}\n\npub struct SessionRecordings {\n    db: Arc<Mutex<DatabaseConnection>>,\n    path: PathBuf,\n    config: RecordingsConfig,\n    live: Arc<Mutex<HashMap<Uuid, broadcast::Sender<Bytes>>>>,\n    params: GlobalParams,\n}\n\nimpl SessionRecordings {\n    pub fn new(\n        db: Arc<Mutex<DatabaseConnection>>,\n        config: &WarpgateConfig,\n        params: &GlobalParams,\n    ) -> Result<Self> {\n        let mut path = params.paths_relative_to().clone();\n        path.push(&config.store.recordings.path);\n        if config.store.recordings.enable {\n            std::fs::create_dir_all(&path)?;\n            if params.should_secure_files() {\n                secure_directory(&path)?;\n            }\n        }\n        Ok(Self {\n            db,\n            config: config.store.recordings.clone(),\n            path,\n            live: Arc::new(Mutex::new(HashMap::new())),\n            params: params.clone(),\n        })\n    }\n\n    /// Starting a recording with the same name again will append to it\n    pub async fn start<T, M>(\n        &mut self,\n        id: &SessionId,\n        name: Option<String>,\n        metadata: M,\n    ) -> Result<T>\n    where\n        T: Recorder,\n        M: Serialize + Debug,\n    {\n        if !self.config.enable {\n            return Err(Error::Disabled);\n        }\n\n        let name = name.unwrap_or_else(|| Uuid::new_v4().to_string());\n        let path = self.path_for(id, &name);\n\n        tokio::fs::create_dir_all(&path.parent().ok_or(Error::InvalidPath)?).await?;\n\n        let model = {\n            let db = self.db.lock().await;\n            let existing = Recording::Entity::find()\n                .filter(\n                    Recording::Column::SessionId\n                        .eq(*id)\n                        .and(Recording::Column::Name.eq(name.clone()))\n                        .and(Recording::Column::Kind.eq(T::kind())),\n                )\n                .one(&*db)\n                .await?;\n            match existing {\n                Some(e) => e,\n                None => {\n                    info!(%name, ?metadata, path=?path, \"Recording session {}\", id);\n                    use sea_orm::ActiveValue::Set;\n                    let values = Recording::ActiveModel {\n                        id: Set(Uuid::new_v4()),\n                        started: Set(chrono::Utc::now()),\n                        session_id: Set(*id),\n                        name: Set(name.clone()),\n                        kind: Set(T::kind()),\n                        metadata: Set(serde_json::to_string(&metadata)?),\n                        ..Default::default()\n                    };\n                    values.insert(&*db).await.map_err(Error::Database)?\n                }\n            }\n        };\n\n        let writer = RecordingWriter::new(\n            path,\n            model,\n            self.db.clone(),\n            self.live.clone(),\n            &self.params,\n        )\n        .await?;\n        Ok(T::new(writer))\n    }\n\n    pub async fn subscribe_live(&self, id: &Uuid) -> Option<broadcast::Receiver<Bytes>> {\n        let live = self.live.lock().await;\n        live.get(id).map(|sender| sender.subscribe())\n    }\n\n    pub async fn remove(&self, session_id: &SessionId, name: &str) -> Result<()> {\n        let path = self.path_for(session_id, name);\n        tokio::fs::remove_file(&path).await?;\n        if let Some(parent) = path.parent() {\n            if tokio::fs::read_dir(parent)\n                .await?\n                .next_entry()\n                .await?\n                .is_none()\n            {\n                tokio::fs::remove_dir(parent).await?;\n            }\n        }\n        Ok(())\n    }\n\n    pub fn path_for<P: AsRef<Path>>(&self, session_id: &SessionId, name: P) -> PathBuf {\n        self.path.join(session_id.to_string()).join(&name)\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/recordings/terminal.rs",
    "content": "use bytes::Bytes;\nuse serde::{Deserialize, Serialize};\nuse tokio::time::Instant;\nuse warpgate_db_entities::Recording::RecordingKind;\n\nuse super::writer::RecordingWriter;\nuse super::{Error, Recorder, Result};\n\n#[derive(Serialize)]\n#[serde(untagged)]\npub enum AsciiCast {\n    Header {\n        time: f32,\n        version: u32,\n        width: u32,\n        height: u32,\n        title: String,\n    },\n    Output(f32, String, String),\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub enum TerminalRecordingStreamId {\n    Input,\n    #[default]\n    Output,\n    Error,\n}\n\nimpl TerminalRecordingStreamId {\n    pub fn from_usual_fd_number(fd: u8) -> Option<Self> {\n        match fd {\n            0 => Some(TerminalRecordingStreamId::Input),\n            1 => Some(TerminalRecordingStreamId::Output),\n            2 => Some(TerminalRecordingStreamId::Error),\n            _ => None,\n        }\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\n#[serde(untagged)]\npub enum TerminalRecordingItem {\n    Data {\n        time: f32,\n        #[serde(default)]\n        stream: TerminalRecordingStreamId,\n        #[serde(with = \"warpgate_common::helpers::serde_base64\")]\n        data: Bytes,\n    },\n    PtyResize {\n        time: f32,\n        cols: u32,\n        rows: u32,\n    },\n}\n\nimpl From<TerminalRecordingItem> for AsciiCast {\n    fn from(item: TerminalRecordingItem) -> Self {\n        match item {\n            TerminalRecordingItem::Data { time, stream, data } => AsciiCast::Output(\n                time,\n                match stream {\n                    TerminalRecordingStreamId::Input => \"i\".to_string(),\n                    TerminalRecordingStreamId::Output => \"o\".to_string(),\n                    TerminalRecordingStreamId::Error => \"e\".to_string(),\n                },\n                String::from_utf8_lossy(&data[..]).to_string(),\n            ),\n            TerminalRecordingItem::PtyResize { time, cols, rows } => AsciiCast::Header {\n                time,\n                version: 2,\n                width: cols,\n                height: rows,\n                title: \"\".to_string(),\n            },\n        }\n    }\n}\n\npub struct TerminalRecorder {\n    writer: RecordingWriter,\n    started_at: Instant,\n}\n\nimpl TerminalRecorder {\n    fn get_time(&self) -> f32 {\n        self.started_at.elapsed().as_secs_f32()\n    }\n\n    async fn write_item(&mut self, item: &TerminalRecordingItem) -> Result<()> {\n        let mut serialized_item = serde_json::to_vec(&item).map_err(Error::Serialization)?;\n        serialized_item.push(b'\\n');\n        self.writer.write(&serialized_item).await?;\n        Ok(())\n    }\n\n    pub async fn write(&mut self, stream: TerminalRecordingStreamId, data: &[u8]) -> Result<()> {\n        self.write_item(&TerminalRecordingItem::Data {\n            time: self.get_time(),\n            stream,\n            data: Bytes::from(data.to_vec()),\n        })\n        .await\n    }\n\n    pub async fn write_pty_resize(&mut self, cols: u32, rows: u32) -> Result<()> {\n        self.write_item(&TerminalRecordingItem::PtyResize {\n            time: self.get_time(),\n            rows,\n            cols,\n        })\n        .await\n    }\n}\n\nimpl Recorder for TerminalRecorder {\n    fn kind() -> RecordingKind {\n        RecordingKind::Terminal\n    }\n\n    fn new(writer: RecordingWriter) -> Self {\n        TerminalRecorder {\n            writer,\n            started_at: Instant::now(),\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/recordings/traffic.rs",
    "content": "use std::net::Ipv4Addr;\n\nuse anyhow::Result;\nuse bytes::Bytes;\nuse packet::Builder;\nuse rand::Rng;\nuse tokio::time::Instant;\nuse tracing::*;\nuse warpgate_db_entities::Recording::RecordingKind;\n\nuse super::writer::RecordingWriter;\nuse super::Recorder;\n\npub struct TrafficRecorder {\n    writer: RecordingWriter,\n    started_at: Instant,\n}\n\n#[derive(Debug)]\npub enum TrafficConnectionParams {\n    Tcp {\n        src_addr: Ipv4Addr,\n        src_port: u16,\n        dst_addr: Ipv4Addr,\n        dst_port: u16,\n    },\n    Socket {\n        socket_path: String,\n    },\n}\n\nimpl TrafficRecorder {\n    pub fn connection(&mut self, params: TrafficConnectionParams) -> ConnectionRecorder {\n        ConnectionRecorder::new(params, self.writer.clone(), self.started_at)\n    }\n}\n\nimpl Recorder for TrafficRecorder {\n    fn kind() -> RecordingKind {\n        RecordingKind::Traffic\n    }\n\n    fn new(writer: RecordingWriter) -> Self {\n        TrafficRecorder {\n            writer,\n            started_at: Instant::now(),\n        }\n    }\n}\n\npub struct ConnectionRecorder {\n    params: TrafficConnectionParams,\n    seq_tx: u32,\n    seq_rx: u32,\n    writer: RecordingWriter,\n    started_at: Instant,\n}\n\nimpl ConnectionRecorder {\n    fn new(params: TrafficConnectionParams, writer: RecordingWriter, started_at: Instant) -> Self {\n        Self {\n            params,\n            writer,\n            started_at,\n            seq_rx: rand::thread_rng().gen(),\n            seq_tx: rand::thread_rng().gen(),\n        }\n    }\n\n    pub async fn write_connection_setup(&mut self) -> Result<()> {\n        self.writer\n            .write(&[\n                0xd4, 0xc3, 0xb2, 0xa1, 0x02, 0, 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0,\n                101, 0, 0, 0,\n            ])\n            .await?;\n        let init = self.tcp_init()?;\n        self.write_packet(init.0).await?;\n        self.write_packet(init.1).await?;\n        self.write_packet(init.2).await?;\n        Ok(())\n    }\n\n    async fn write_packet(&mut self, data: Bytes) -> Result<()> {\n        let ms = Instant::now().duration_since(self.started_at).as_micros();\n        self.writer\n            .write(&u32::to_le_bytes((ms / 10u128.pow(6)) as u32))\n            .await?;\n        self.writer\n            .write(&u32::to_le_bytes((ms % 10u128.pow(6)) as u32))\n            .await?;\n        self.writer\n            .write(&u32::to_le_bytes(data.len() as u32))\n            .await?;\n        self.writer\n            .write(&u32::to_le_bytes(data.len() as u32))\n            .await?;\n        self.writer.write(&data).await?;\n        Ok(())\n    }\n\n    pub async fn write_rx(&mut self, data: &[u8]) -> Result<()> {\n        debug!(\"connection {:?} data rx {:?}\", self.params, data);\n        let seq_rx = self.seq_rx;\n        self.seq_rx = self.seq_rx.wrapping_add(data.len() as u32);\n        self.write_packet(\n            self.tcp_packet_rx(|b| Ok(b.sequence(seq_rx)?.payload(data)?.build()?.into()))?,\n        )\n        .await?;\n        self.write_packet(self.tcp_packet_tx(|b| {\n            Ok(b.sequence(self.seq_tx)?\n                .acknowledgment(seq_rx + 1)?\n                .flags(packet::tcp::Flags::ACK)?\n                .build()?\n                .into())\n        })?)\n        .await?;\n        Ok(())\n    }\n\n    pub async fn write_tx(&mut self, data: &[u8]) -> Result<()> {\n        debug!(\"connection {:?} data tx {:?}\", self.params, data);\n        let seq_tx = self.seq_tx;\n        self.seq_tx = self.seq_tx.wrapping_add(data.len() as u32);\n        self.write_packet(\n            self.tcp_packet_tx(|b| Ok(b.sequence(seq_tx)?.payload(data)?.build()?.into()))?,\n        )\n        .await?;\n        self.write_packet(self.tcp_packet_rx(|b| {\n            Ok(b.sequence(self.seq_rx)?\n                .acknowledgment(seq_tx + 1)?\n                .flags(packet::tcp::Flags::ACK)?\n                .build()?\n                .into())\n        })?)\n        .await?;\n        Ok(())\n    }\n\n    fn ip_packet_tx<F>(&self, f: F) -> Result<Bytes>\n    where\n        F: FnOnce(packet::ip::v4::Builder) -> Result<Bytes>,\n    {\n        match self.params {\n            TrafficConnectionParams::Socket { .. } => f(packet::ip::v4::Builder::default()\n                .protocol(packet::ip::Protocol::Tcp)?\n                .source(Ipv4Addr::UNSPECIFIED)?\n                .destination(Ipv4Addr::BROADCAST)?),\n            TrafficConnectionParams::Tcp {\n                src_addr, dst_addr, ..\n            } => f(packet::ip::v4::Builder::default()\n                .protocol(packet::ip::Protocol::Tcp)?\n                .source(src_addr)?\n                .destination(dst_addr)?),\n        }\n    }\n\n    fn ip_packet_rx<F>(&self, f: F) -> Result<Bytes>\n    where\n        F: FnOnce(packet::ip::v4::Builder) -> Result<Bytes>,\n    {\n        match self.params {\n            TrafficConnectionParams::Socket { .. } => f(packet::ip::v4::Builder::default()\n                .protocol(packet::ip::Protocol::Tcp)?\n                .source(Ipv4Addr::BROADCAST)?\n                .destination(Ipv4Addr::UNSPECIFIED)?),\n            TrafficConnectionParams::Tcp {\n                src_addr, dst_addr, ..\n            } => f(packet::ip::v4::Builder::default()\n                .protocol(packet::ip::Protocol::Tcp)?\n                .source(dst_addr)?\n                .destination(src_addr)?),\n        }\n    }\n\n    fn tcp_packet_tx<F>(&self, f: F) -> Result<Bytes>\n    where\n        F: FnOnce(packet::tcp::Builder) -> Result<Bytes>,\n    {\n        self.ip_packet_tx(|b| match self.params {\n            TrafficConnectionParams::Socket { .. } => f(b.tcp()?.source(0)?.destination(0)?),\n            TrafficConnectionParams::Tcp {\n                src_port, dst_port, ..\n            } => f(b.tcp()?.source(src_port)?.destination(dst_port)?),\n        })\n    }\n\n    fn tcp_packet_rx<F>(&self, f: F) -> Result<Bytes>\n    where\n        F: FnOnce(packet::tcp::Builder) -> Result<Bytes>,\n    {\n        self.ip_packet_rx(|b| match self.params {\n            TrafficConnectionParams::Socket { .. } => f(b.tcp()?.source(0)?.destination(0)?),\n            TrafficConnectionParams::Tcp {\n                src_port, dst_port, ..\n            } => f(b.tcp()?.source(dst_port)?.destination(src_port)?),\n        })\n    }\n\n    fn tcp_init(&mut self) -> Result<(Bytes, Bytes, Bytes)> {\n        let seq_tx = self.seq_tx;\n        self.seq_tx = self.seq_tx.wrapping_add(1);\n        let seq_rx = self.seq_rx;\n        self.seq_rx = self.seq_rx.wrapping_add(1);\n\n        Ok((\n            self.tcp_packet_tx(|b| {\n                Ok(b.sequence(seq_tx)?\n                    .flags(packet::tcp::Flags::SYN)?\n                    .build()?\n                    .into())\n            })?,\n            self.tcp_packet_rx(|b| {\n                Ok(b.sequence(seq_rx)?\n                    .acknowledgment(seq_tx + 1)?\n                    .flags(packet::tcp::Flags::SYN | packet::tcp::Flags::ACK)?\n                    .build()?\n                    .into())\n            })?,\n            self.tcp_packet_tx(|b| {\n                Ok(b.sequence(seq_tx + 1)?\n                    .acknowledgment(seq_rx + 1)?\n                    .flags(packet::tcp::Flags::ACK)?\n                    .build()?\n                    .into())\n            })?,\n        ))\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/recordings/writer.rs",
    "content": "use std::collections::HashMap;\nuse std::path::PathBuf;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\n\nuse bytes::Bytes;\nuse sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait};\nuse tokio::fs::File;\nuse tokio::io::{AsyncWriteExt, BufWriter};\nuse tokio::sync::{broadcast, mpsc, Mutex};\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::helpers::fs::secure_file;\nuse warpgate_common::{try_block, GlobalParams};\nuse warpgate_db_entities::Recording;\n\nuse super::{Error, Result};\n\n#[derive(Clone)]\npub struct RecordingWriter {\n    sender: mpsc::Sender<Bytes>,\n    live_sender: broadcast::Sender<Bytes>,\n    drop_signal: mpsc::Sender<()>,\n}\n\nimpl RecordingWriter {\n    pub(crate) async fn new(\n        path: PathBuf,\n        model: Recording::Model,\n        db: Arc<Mutex<DatabaseConnection>>,\n        live: Arc<Mutex<HashMap<Uuid, broadcast::Sender<Bytes>>>>,\n        params: &GlobalParams,\n    ) -> Result<Self> {\n        let file = File::options()\n            .append(true)\n            .create(true)\n            .open(&path)\n            .await?;\n        if params.should_secure_files() {\n            secure_file(&path)?;\n        }\n        let mut writer = BufWriter::new(file);\n        let (sender, mut receiver) = mpsc::channel::<Bytes>(1024);\n        let (drop_signal, mut drop_receiver) = mpsc::channel(1);\n\n        let live_sender = broadcast::channel(128).0;\n        {\n            let mut live = live.lock().await;\n            live.insert(model.id, live_sender.clone());\n        }\n\n        tokio::spawn({\n            let live = live.clone();\n            let id = model.id;\n            async move {\n                let _ = drop_receiver.recv().await;\n                let mut live = live.lock().await;\n                live.remove(&id);\n            }\n        });\n\n        tokio::spawn(async move {\n            try_block!(async {\n                let mut last_flush = Instant::now();\n                loop {\n                    if Instant::now() - last_flush > Duration::from_secs(5) {\n                        last_flush = Instant::now();\n                        writer.flush().await?;\n                    }\n                    tokio::select! {\n                        data = receiver.recv() => match data {\n                            Some(bytes) => {\n                                writer.write_all(&bytes).await?;\n                            }\n                            None => break,\n                        },\n                        _ = tokio::time::sleep(Duration::from_millis(5000)) => ()\n                    }\n                }\n                Ok::<(), anyhow::Error>(())\n            } catch (error: anyhow::Error) {\n                error!(%error, ?path, \"Failed to write recording\");\n            });\n\n            try_block!(async {\n                writer.flush().await?;\n\n                use sea_orm::ActiveValue::Set;\n                let id = model.id;\n                let db = db.lock().await;\n                let recording = Recording::Entity::find_by_id(id)\n                    .one(&*db)\n                    .await?\n                    .ok_or_else(|| anyhow::anyhow!(\"Recording not found\"))?;\n                let mut model: Recording::ActiveModel = recording.into();\n                model.ended = Set(Some(chrono::Utc::now()));\n                model.update(&*db).await?;\n                Ok::<(), anyhow::Error>(())\n            } catch (error: anyhow::Error) {\n                error!(%error, ?path, \"Failed to write recording\");\n            });\n        });\n\n        Ok(RecordingWriter {\n            sender,\n            live_sender,\n            drop_signal,\n        })\n    }\n\n    pub async fn write(&mut self, data: &[u8]) -> Result<()> {\n        let data = Bytes::from(data.to_vec());\n        self.sender\n            .send(data.clone())\n            .await\n            .map_err(|_| Error::Closed)?;\n        let _ = self.live_sender.send(data);\n        Ok(())\n    }\n}\n\nimpl Drop for RecordingWriter {\n    fn drop(&mut self) {\n        let signal = std::mem::replace(&mut self.drop_signal, mpsc::channel(1).0);\n        tokio::spawn(async move { signal.send(()).await });\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/services.rs",
    "content": "use std::sync::Arc;\nuse std::time::Duration;\n\nuse anyhow::Result;\nuse sea_orm::DatabaseConnection;\nuse tokio::sync::Mutex;\nuse warpgate_common::{GlobalParams, WarpgateConfig};\n\nuse crate::db::{connect_to_db, populate_db};\nuse crate::rate_limiting::RateLimiterRegistry;\nuse crate::recordings::SessionRecordings;\nuse crate::{AuthStateStore, ConfigProviderEnum, DatabaseConfigProvider, State};\n\n#[derive(Clone)]\npub struct Services {\n    pub db: Arc<Mutex<DatabaseConnection>>,\n    pub recordings: Arc<Mutex<SessionRecordings>>,\n    pub config: Arc<Mutex<WarpgateConfig>>,\n    pub state: Arc<Mutex<State>>,\n    pub config_provider: Arc<Mutex<ConfigProviderEnum>>,\n    pub auth_state_store: Arc<Mutex<AuthStateStore>>,\n    pub admin_token: Arc<Mutex<Option<String>>>,\n    pub rate_limiter_registry: Arc<Mutex<RateLimiterRegistry>>,\n    pub global_params: Arc<GlobalParams>,\n}\n\nimpl Services {\n    pub async fn new(\n        mut config: WarpgateConfig,\n        admin_token: Option<String>,\n        params: GlobalParams,\n    ) -> Result<Self> {\n        let mut db = connect_to_db(&config, &params).await?;\n        populate_db(&mut db, &mut config).await?;\n        let db = Arc::new(Mutex::new(db));\n\n        let recordings = SessionRecordings::new(db.clone(), &config, &params)?;\n        let recordings = Arc::new(Mutex::new(recordings));\n\n        let config = Arc::new(Mutex::new(config));\n\n        let config_provider = Arc::new(Mutex::new(DatabaseConfigProvider::new(&db).await.into()));\n\n        let auth_state_store = Arc::new(Mutex::new(AuthStateStore::new(config_provider.clone())));\n\n        tokio::spawn({\n            let auth_state_store = auth_state_store.clone();\n            async move {\n                loop {\n                    auth_state_store.lock().await.vacuum().await;\n                    tokio::time::sleep(Duration::from_secs(60)).await;\n                }\n            }\n        });\n\n        let mut rate_limiter_registry = RateLimiterRegistry::new(db.clone());\n        rate_limiter_registry.refresh().await?;\n        let rate_limiter_registry = Arc::new(Mutex::new(rate_limiter_registry));\n\n        Ok(Self {\n            db: db.clone(),\n            recordings,\n            config: config.clone(),\n            state: State::new(&db, &rate_limiter_registry)?,\n            rate_limiter_registry,\n            config_provider,\n            auth_state_store,\n            admin_token: Arc::new(Mutex::new(admin_token)),\n            global_params: Arc::new(params),\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-core/src/state.rs",
    "content": "use std::collections::HashMap;\nuse std::net::SocketAddr;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait};\nuse tokio::sync::{broadcast, Mutex};\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::auth::AuthStateUserInfo;\nuse warpgate_common::{ProtocolName, SessionId, Target, WarpgateError};\nuse warpgate_db_entities::Session;\n\nuse crate::rate_limiting::{RateLimiterRegistry, RateLimiterStackHandle};\nuse crate::{SessionHandle, WarpgateServerHandle};\n\npub struct State {\n    pub sessions: HashMap<SessionId, Arc<Mutex<SessionState>>>,\n    db: Arc<Mutex<DatabaseConnection>>,\n    rate_limiter_registry: Arc<Mutex<RateLimiterRegistry>>,\n    change_sender: broadcast::Sender<()>,\n}\n\nimpl State {\n    pub fn new(\n        db: &Arc<Mutex<DatabaseConnection>>,\n        rate_limiter_registry: &Arc<Mutex<RateLimiterRegistry>>,\n    ) -> Result<Arc<Mutex<Self>>, WarpgateError> {\n        let sender = broadcast::channel(2).0;\n        Ok(Arc::new(Mutex::new(Self {\n            sessions: HashMap::new(),\n            db: db.clone(),\n            rate_limiter_registry: rate_limiter_registry.clone(),\n            change_sender: sender,\n        })))\n    }\n\n    pub async fn register_session(\n        this: &Arc<Mutex<Self>>,\n        protocol: &ProtocolName,\n        state: SessionStateInit,\n    ) -> Result<Arc<Mutex<WarpgateServerHandle>>, WarpgateError> {\n        let this_copy = this.clone();\n        let mut _self = this.lock().await;\n        let id = uuid::Uuid::new_v4();\n\n        let state = Arc::new(Mutex::new(SessionState::new(\n            state,\n            _self.change_sender.clone(),\n        )));\n\n        _self.sessions.insert(id, state.clone());\n\n        {\n            use sea_orm::ActiveValue::Set;\n\n            let values = Session::ActiveModel {\n                id: Set(id),\n                started: Set(chrono::Utc::now()),\n                remote_address: Set(state\n                    .lock()\n                    .await\n                    .remote_address\n                    .map(|x| x.to_string())\n                    .unwrap_or_else(|| \"\".to_string())),\n                protocol: Set(protocol.to_string()),\n                ..Default::default()\n            };\n\n            let db = _self.db.lock().await;\n            values\n                .insert(&*db)\n                .await\n                .context(\"Error inserting session\")\n                .map_err(WarpgateError::from)?;\n        }\n\n        let _ = _self.change_sender.send(());\n\n        Ok(Arc::new(Mutex::new(WarpgateServerHandle::new(\n            id,\n            _self.db.clone(),\n            this_copy,\n            state,\n            _self.rate_limiter_registry.clone(),\n        )?)))\n    }\n\n    pub fn subscribe(&mut self) -> broadcast::Receiver<()> {\n        self.change_sender.subscribe()\n    }\n\n    pub async fn remove_session(&mut self, id: SessionId) {\n        self.sessions.remove(&id);\n\n        if let Err(error) = self.mark_session_complete(id).await {\n            error!(%error, %id, \"Could not update session in the DB\");\n        }\n\n        let _ = self.change_sender.send(());\n    }\n\n    async fn mark_session_complete(&mut self, id: Uuid) -> Result<()> {\n        use sea_orm::ActiveValue::Set;\n        let db = self.db.lock().await;\n        let session = Session::Entity::find_by_id(id)\n            .one(&*db)\n            .await?\n            .ok_or_else(|| anyhow::anyhow!(\"Session not found\"))?;\n        let mut model: Session::ActiveModel = session.into();\n        model.ended = Set(Some(chrono::Utc::now()));\n        model.update(&*db).await?;\n        Ok(())\n    }\n}\n\npub struct SessionState {\n    pub remote_address: Option<SocketAddr>,\n    pub user_info: Option<AuthStateUserInfo>,\n    pub target: Option<Target>,\n    pub handle: Box<dyn SessionHandle + Send + Sync>,\n    change_sender: broadcast::Sender<()>,\n    pub rate_limiter_handles: Vec<RateLimiterStackHandle>,\n}\n\npub struct SessionStateInit {\n    pub remote_address: Option<SocketAddr>,\n    pub handle: Box<dyn SessionHandle + Send + Sync>,\n}\n\nimpl SessionState {\n    fn new(init: SessionStateInit, change_sender: broadcast::Sender<()>) -> Self {\n        SessionState {\n            remote_address: init.remote_address,\n            user_info: None,\n            target: None,\n            handle: init.handle,\n            change_sender,\n            rate_limiter_handles: vec![],\n        }\n    }\n\n    pub fn emit_change(&self) {\n        let _ = self.change_sender.send(());\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/Cargo.toml",
    "content": "[package]\nname = \"warpgate-database-protocols\"\nversion = \"0.22.0\"\ndescription = \"Core of SQLx, the rust SQL toolkit. Just the database protocol parts.\"\nlicense = \"MIT OR Apache-2.0\"\nedition = \"2021\"\nauthors = [\n    \"Ryan Leckey <leckey.ryan@gmail.com>\",\n    \"Austin Bonander <austin.bonander@gmail.com>\",\n    \"Chloe Ross <orangesnowfox@gmail.com>\",\n    \"Daniel Akhterov <akhterovd@gmail.com>\",\n]\n\n[dependencies]\ntokio.workspace = true\nbitflags = { version = \"2\", default-features = false }\nbytes.workspace = true\nfutures-core = { version = \"0.3\", default-features = false }\nfutures-util = { version = \"0.3\", default-features = false, features = [\n    \"alloc\",\n    \"sink\",\n] }\nmemchr = { version = \"2.5\", default-features = false }\nthiserror.workspace = true\n"
  },
  {
    "path": "warpgate-database-protocols/README.md",
    "content": "This is an extract from sqlx-core with Encode/Decode impls added for server-side packet flow\n"
  },
  {
    "path": "warpgate-database-protocols/src/error.rs",
    "content": "//! Types for working with errors produced by SQLx.\n\nuse std::borrow::Cow;\nuse std::error::Error as StdError;\nuse std::fmt::Display;\nuse std::io;\nuse std::result::Result as StdResult;\n\n/// A specialized `Result` type for SQLx.\npub type Result<T> = StdResult<T, Error>;\n\n// Convenience type alias for usage within SQLx.\n// Do not make this type public.\npub type BoxDynError = Box<dyn StdError + 'static + Send + Sync>;\n\n/// An unexpected `NULL` was encountered during decoding.\n///\n/// Returned from [`Row::get`](crate::row::Row::get) if the value from the database is `NULL`,\n/// and you are not decoding into an `Option`.\n#[derive(thiserror::Error, Debug)]\n#[error(\"unexpected null; try decoding as an `Option`\")]\npub struct UnexpectedNullError;\n\n/// Represents all the ways a method can fail within SQLx.\n#[derive(Debug, thiserror::Error)]\n#[non_exhaustive]\npub enum Error {\n    /// Error occurred while parsing a connection string.\n    #[error(\"error with configuration: {0}\")]\n    Configuration(#[source] BoxDynError),\n\n    /// Error returned from the database.\n    #[error(\"error returned from database: {0}\")]\n    Database(#[source] Box<dyn DatabaseError>),\n\n    /// Error communicating with the database backend.\n    #[error(\"error communicating with database: {0}\")]\n    Io(#[from] io::Error),\n\n    /// Error occurred while attempting to establish a TLS connection.\n    #[error(\"error occurred while attempting to establish a TLS connection: {0}\")]\n    Tls(#[source] BoxDynError),\n\n    /// Unexpected or invalid data encountered while communicating with the database.\n    ///\n    /// This should indicate there is a programming error in a SQLx driver or there\n    /// is something corrupted with the connection to the database itself.\n    #[error(\"encountered unexpected or invalid data: {0}\")]\n    Protocol(String),\n\n    /// No rows returned by a query that expected to return at least one row.\n    #[error(\"no rows returned by a query that expected to return at least one row\")]\n    RowNotFound,\n\n    /// Type in query doesn't exist. Likely due to typo or missing user type.\n    #[error(\"type named {type_name} not found\")]\n    TypeNotFound { type_name: String },\n\n    /// Column index was out of bounds.\n    #[error(\"column index out of bounds: the len is {len}, but the index is {index}\")]\n    ColumnIndexOutOfBounds { index: usize, len: usize },\n\n    /// No column found for the given name.\n    #[error(\"no column found for name: {0}\")]\n    ColumnNotFound(String),\n\n    /// Error occurred while decoding a value from a specific column.\n    #[error(\"error occurred while decoding column {index}: {source}\")]\n    ColumnDecode {\n        index: String,\n\n        #[source]\n        source: BoxDynError,\n    },\n\n    /// Error occurred while decoding a value.\n    #[error(\"error occurred while decoding: {0}\")]\n    Decode(#[source] BoxDynError),\n\n    /// A [`Pool::acquire`] timed out due to connections not becoming available or\n    /// because another task encountered too many errors while trying to open a new connection.\n    ///\n    /// [`Pool::acquire`]: crate::pool::Pool::acquire\n    #[error(\"pool timed out while waiting for an open connection\")]\n    PoolTimedOut,\n\n    /// [`Pool::close`] was called while we were waiting in [`Pool::acquire`].\n    ///\n    /// [`Pool::acquire`]: crate::pool::Pool::acquire\n    /// [`Pool::close`]: crate::pool::Pool::close\n    #[error(\"attempted to acquire a connection on a closed pool\")]\n    PoolClosed,\n\n    /// A background worker has crashed.\n    #[error(\"attempted to communicate with a crashed background worker\")]\n    WorkerCrashed,\n}\n\nimpl StdError for Box<dyn DatabaseError> {}\n\nimpl Error {\n    #[allow(dead_code)]\n    #[inline]\n    pub(crate) fn protocol(err: impl Display) -> Self {\n        Error::Protocol(err.to_string())\n    }\n\n    #[allow(dead_code)]\n    #[inline]\n    pub(crate) fn config(err: impl StdError + Send + Sync + 'static) -> Self {\n        Error::Configuration(err.into())\n    }\n}\n\n/// An error that was returned from the database.\npub trait DatabaseError: 'static + Send + Sync + StdError {\n    /// The primary, human-readable error message.\n    fn message(&self) -> &str;\n\n    /// The (SQLSTATE) code for the error.\n    fn code(&self) -> Option<Cow<'_, str>> {\n        None\n    }\n\n    #[doc(hidden)]\n    fn as_error(&self) -> &(dyn StdError + Send + Sync + 'static);\n\n    #[doc(hidden)]\n    fn as_error_mut(&mut self) -> &mut (dyn StdError + Send + Sync + 'static);\n\n    #[doc(hidden)]\n    fn into_error(self: Box<Self>) -> Box<dyn StdError + Send + Sync + 'static>;\n\n    #[doc(hidden)]\n    fn is_transient_in_connect_phase(&self) -> bool {\n        false\n    }\n\n    /// Returns the name of the constraint that triggered the error, if applicable.\n    /// If the error was caused by a conflict of a unique index, this will be the index name.\n    ///\n    /// ### Note\n    /// Currently only populated by the Postgres driver.\n    fn constraint(&self) -> Option<&str> {\n        None\n    }\n}\n\nimpl dyn DatabaseError {\n    /// Downcast a reference to this generic database error to a specific\n    /// database error type.\n    #[inline]\n    pub fn try_downcast_ref<E: DatabaseError>(&self) -> Option<&E> {\n        self.as_error().downcast_ref()\n    }\n\n    /// Downcast this generic database error to a specific database error type.\n    #[inline]\n    pub fn try_downcast<E: DatabaseError>(self: Box<Self>) -> StdResult<Box<E>, Box<Self>> {\n        if self.as_error().is::<E>() {\n            #[allow(clippy::unwrap_used)]\n            Ok(self.into_error().downcast().unwrap())\n        } else {\n            Err(self)\n        }\n    }\n}\n\nimpl<E> From<E> for Error\nwhere\n    E: DatabaseError,\n{\n    #[inline]\n    fn from(error: E) -> Self {\n        Error::Database(Box::new(error))\n    }\n}\n\n// Format an error message as a `Protocol` error\n#[macro_export]\nmacro_rules! err_protocol {\n    ($expr:expr) => {\n        $crate::error::Error::Protocol($expr.into())\n    };\n\n    ($fmt:expr, $($arg:tt)*) => {\n        $crate::error::Error::Protocol(format!($fmt, $($arg)*))\n    };\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/io/buf.rs",
    "content": "use std::str::from_utf8;\n\nuse bytes::{Buf, Bytes};\nuse memchr::memchr;\n\nuse crate::err_protocol;\nuse crate::error::Error;\n\npub trait BufExt: Buf {\n    // Read a nul-terminated byte sequence\n    fn get_bytes_nul(&mut self) -> Result<Bytes, Error>;\n\n    // Read a byte sequence of the exact length\n    fn get_bytes(&mut self, len: usize) -> Bytes;\n\n    // Read a nul-terminated string\n    fn get_str_nul(&mut self) -> Result<String, Error>;\n\n    // Read a string of the exact length\n    fn get_str(&mut self, len: usize) -> Result<String, Error>;\n}\n\nimpl BufExt for Bytes {\n    fn get_bytes_nul(&mut self) -> Result<Bytes, Error> {\n        let nul =\n            memchr(b'\\0', self).ok_or_else(|| err_protocol!(\"expected NUL in byte sequence\"))?;\n\n        let v = self.slice(0..nul);\n\n        self.advance(nul + 1);\n\n        Ok(v)\n    }\n\n    fn get_bytes(&mut self, len: usize) -> Bytes {\n        let v = self.slice(..len);\n        self.advance(len);\n\n        v\n    }\n\n    fn get_str_nul(&mut self) -> Result<String, Error> {\n        self.get_bytes_nul().and_then(|bytes| {\n            from_utf8(&bytes)\n                .map(ToOwned::to_owned)\n                .map_err(|err| err_protocol!(\"{}\", err))\n        })\n    }\n\n    fn get_str(&mut self, len: usize) -> Result<String, Error> {\n        let v = from_utf8(&self[..len])\n            .map_err(|err| err_protocol!(\"{}\", err))\n            .map(ToOwned::to_owned)?;\n\n        self.advance(len);\n\n        Ok(v)\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/io/buf_mut.rs",
    "content": "use bytes::BufMut;\n\npub trait BufMutExt: BufMut {\n    fn put_str_nul(&mut self, s: &str);\n}\n\nimpl BufMutExt for Vec<u8> {\n    fn put_str_nul(&mut self, s: &str) {\n        self.extend(s.as_bytes());\n        self.push(0);\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/io/buf_stream.rs",
    "content": "#![allow(dead_code)]\n\nuse std::io;\nuse std::io::Cursor;\nuse std::ops::{Deref, DerefMut};\n\nuse bytes::BytesMut;\nuse tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};\n\nuse crate::error::Error;\nuse crate::io::decode::Decode;\nuse crate::io::encode::Encode;\nuse crate::io::write_and_flush::WriteAndFlush;\n\npub struct BufStream<S>\nwhere\n    S: AsyncRead + AsyncWrite + Unpin,\n{\n    pub(crate) stream: S,\n\n    // writes with `write` to the underlying stream are buffered\n    // this can be flushed with `flush`\n    pub(crate) wbuf: Vec<u8>,\n\n    // we read into the read buffer using 100% safe code\n    rbuf: BytesMut,\n}\n\nimpl<S> BufStream<S>\nwhere\n    S: AsyncRead + AsyncWrite + Unpin,\n{\n    pub fn new(stream: S) -> Self {\n        Self {\n            stream,\n            wbuf: Vec::with_capacity(512),\n            rbuf: BytesMut::with_capacity(4096),\n        }\n    }\n\n    pub fn write<'en, T>(&mut self, value: T)\n    where\n        T: Encode<'en, ()>,\n    {\n        self.write_with(value, ())\n    }\n\n    pub fn write_with<'en, T, C>(&mut self, value: T, context: C)\n    where\n        T: Encode<'en, C>,\n    {\n        value.encode_with(&mut self.wbuf, context);\n    }\n\n    pub fn flush(&mut self) -> WriteAndFlush<'_, S> {\n        WriteAndFlush {\n            stream: &mut self.stream,\n            buf: Cursor::new(&mut self.wbuf),\n        }\n    }\n\n    pub async fn read<'de, T>(&mut self, cnt: usize) -> Result<T, Error>\n    where\n        T: Decode<'de, ()>,\n    {\n        self.read_with(cnt, ()).await\n    }\n\n    pub async fn read_with<'de, T, C>(&mut self, cnt: usize, context: C) -> Result<T, Error>\n    where\n        T: Decode<'de, C>,\n    {\n        T::decode_with(self.read_raw(cnt).await?.freeze(), context)\n    }\n\n    pub async fn read_raw(&mut self, cnt: usize) -> Result<BytesMut, Error> {\n        read_raw_into(&mut self.stream, &mut self.rbuf, cnt).await?;\n        let buf = self.rbuf.split_to(cnt);\n\n        Ok(buf)\n    }\n\n    pub async fn read_raw_into(&mut self, buf: &mut BytesMut, cnt: usize) -> Result<(), Error> {\n        read_raw_into(&mut self.stream, buf, cnt).await\n    }\n\n    pub fn take(self) -> S {\n        self.stream\n    }\n}\n\nimpl<S> Deref for BufStream<S>\nwhere\n    S: AsyncRead + AsyncWrite + Unpin,\n{\n    type Target = S;\n\n    fn deref(&self) -> &Self::Target {\n        &self.stream\n    }\n}\n\nimpl<S> DerefMut for BufStream<S>\nwhere\n    S: AsyncRead + AsyncWrite + Unpin,\n{\n    fn deref_mut(&mut self) -> &mut Self::Target {\n        &mut self.stream\n    }\n}\n\n// Holds a buffer which has been temporarily extended, so that\n// we can read into it. Automatically shrinks the buffer back\n// down if the read is cancelled.\nstruct BufTruncator<'a> {\n    buf: &'a mut BytesMut,\n    filled_len: usize,\n}\n\nimpl<'a> BufTruncator<'a> {\n    fn new(buf: &'a mut BytesMut) -> Self {\n        let filled_len = buf.len();\n        Self { buf, filled_len }\n    }\n    fn reserve(&mut self, space: usize) {\n        self.buf.resize(self.filled_len + space, 0);\n    }\n    async fn read<S: AsyncRead + Unpin>(&mut self, stream: &mut S) -> Result<usize, Error> {\n        let n = stream.read(&mut self.buf[self.filled_len..]).await?;\n        self.filled_len += n;\n        Ok(n)\n    }\n    fn is_full(&self) -> bool {\n        self.filled_len >= self.buf.len()\n    }\n}\n\nimpl Drop for BufTruncator<'_> {\n    fn drop(&mut self) {\n        self.buf.truncate(self.filled_len);\n    }\n}\n\nasync fn read_raw_into<S: AsyncRead + Unpin>(\n    stream: &mut S,\n    buf: &mut BytesMut,\n    cnt: usize,\n) -> Result<(), Error> {\n    let mut buf = BufTruncator::new(buf);\n    buf.reserve(cnt);\n\n    while !buf.is_full() {\n        let n = buf.read(stream).await?;\n\n        if n == 0 {\n            // a zero read when we had space in the read buffer\n            // should be treated as an EOF\n\n            // and an unexpected EOF means the server told us to go away\n\n            return Err(io::Error::from(io::ErrorKind::ConnectionAborted).into());\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/io/decode.rs",
    "content": "use bytes::Bytes;\n\nuse crate::error::Error;\n\npub trait Decode<'de, Context = ()>\nwhere\n    Self: Sized,\n{\n    fn decode(buf: Bytes) -> Result<Self, Error>\n    where\n        Self: Decode<'de, ()>,\n    {\n        Self::decode_with(buf, ())\n    }\n\n    fn decode_with(buf: Bytes, context: Context) -> Result<Self, Error>;\n}\n\nimpl Decode<'_> for Bytes {\n    fn decode_with(buf: Bytes, _: ()) -> Result<Self, Error> {\n        Ok(buf)\n    }\n}\n\nimpl Decode<'_> for () {\n    fn decode_with(_: Bytes, _: ()) -> Result<(), Error> {\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/io/encode.rs",
    "content": "pub trait Encode<'en, Context = ()> {\n    fn encode(&self, buf: &mut Vec<u8>)\n    where\n        Self: Encode<'en, ()>,\n    {\n        self.encode_with(buf, ());\n    }\n\n    fn encode_with(&self, buf: &mut Vec<u8>, context: Context);\n}\n\nimpl<C> Encode<'_, C> for &'_ [u8] {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: C) {\n        buf.extend_from_slice(self);\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/io/mod.rs",
    "content": "mod buf;\nmod buf_mut;\nmod buf_stream;\nmod decode;\nmod encode;\nmod write_and_flush;\n\npub use buf::BufExt;\npub use buf_mut::BufMutExt;\npub use buf_stream::BufStream;\npub use decode::Decode;\npub use encode::Encode;\n"
  },
  {
    "path": "warpgate-database-protocols/src/io/write_and_flush.rs",
    "content": "use std::io::{BufRead, Cursor};\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\n\nuse futures_core::Future;\nuse futures_util::ready;\nuse tokio::io::AsyncWrite;\n\nuse crate::error::Error;\n\n// Atomic operation that writes the full buffer to the stream, flushes the stream, and then\n// clears the buffer (even if either of the two previous operations failed).\npub struct WriteAndFlush<'a, S> {\n    pub(super) stream: &'a mut S,\n    pub(super) buf: Cursor<&'a mut Vec<u8>>,\n}\n\nimpl<S: AsyncWrite + Unpin> Future for WriteAndFlush<'_, S> {\n    type Output = Result<(), Error>;\n\n    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {\n        let Self {\n            ref mut stream,\n            ref mut buf,\n        } = *self;\n\n        loop {\n            let read = buf.fill_buf()?;\n\n            if !read.is_empty() {\n                let written = ready!(Pin::new(&mut *stream).poll_write(cx, read)?);\n                buf.consume(written);\n            } else {\n                break;\n            }\n        }\n\n        Pin::new(stream).poll_flush(cx).map_err(Error::Io)\n    }\n}\n\nimpl<S> Drop for WriteAndFlush<'_, S> {\n    fn drop(&mut self) {\n        // clear the buffer regardless of whether the flush succeeded or not\n        self.buf.get_mut().clear();\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/lib.rs",
    "content": "#![allow(dead_code, clippy::indexing_slicing)]\npub mod io;\npub mod mysql;\n\n#[macro_use]\npub mod error;\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/collation.rs",
    "content": "use std::str::FromStr;\n\nuse crate::error::Error;\n\n#[allow(non_camel_case_types)]\n#[derive(Copy, Clone)]\npub(crate) enum CharSet {\n    armscii8,\n    ascii,\n    big5,\n    binary,\n    cp1250,\n    cp1251,\n    cp1256,\n    cp1257,\n    cp850,\n    cp852,\n    cp866,\n    cp932,\n    dec8,\n    eucjpms,\n    euckr,\n    gb18030,\n    gb2312,\n    gbk,\n    geostd8,\n    greek,\n    hebrew,\n    hp8,\n    keybcs2,\n    koi8r,\n    koi8u,\n    latin1,\n    latin2,\n    latin5,\n    latin7,\n    macce,\n    macroman,\n    sjis,\n    swe7,\n    tis620,\n    ucs2,\n    ujis,\n    utf16,\n    utf16le,\n    utf32,\n    utf8,\n    utf8mb4,\n}\n\nimpl CharSet {\n    pub(crate) fn as_str(&self) -> &'static str {\n        match self {\n            CharSet::armscii8 => \"armscii8\",\n            CharSet::ascii => \"ascii\",\n            CharSet::big5 => \"big5\",\n            CharSet::binary => \"binary\",\n            CharSet::cp1250 => \"cp1250\",\n            CharSet::cp1251 => \"cp1251\",\n            CharSet::cp1256 => \"cp1256\",\n            CharSet::cp1257 => \"cp1257\",\n            CharSet::cp850 => \"cp850\",\n            CharSet::cp852 => \"cp852\",\n            CharSet::cp866 => \"cp866\",\n            CharSet::cp932 => \"cp932\",\n            CharSet::dec8 => \"dec8\",\n            CharSet::eucjpms => \"eucjpms\",\n            CharSet::euckr => \"euckr\",\n            CharSet::gb18030 => \"gb18030\",\n            CharSet::gb2312 => \"gb2312\",\n            CharSet::gbk => \"gbk\",\n            CharSet::geostd8 => \"geostd8\",\n            CharSet::greek => \"greek\",\n            CharSet::hebrew => \"hebrew\",\n            CharSet::hp8 => \"hp8\",\n            CharSet::keybcs2 => \"keybcs2\",\n            CharSet::koi8r => \"koi8r\",\n            CharSet::koi8u => \"koi8u\",\n            CharSet::latin1 => \"latin1\",\n            CharSet::latin2 => \"latin2\",\n            CharSet::latin5 => \"latin5\",\n            CharSet::latin7 => \"latin7\",\n            CharSet::macce => \"macce\",\n            CharSet::macroman => \"macroman\",\n            CharSet::sjis => \"sjis\",\n            CharSet::swe7 => \"swe7\",\n            CharSet::tis620 => \"tis620\",\n            CharSet::ucs2 => \"ucs2\",\n            CharSet::ujis => \"ujis\",\n            CharSet::utf16 => \"utf16\",\n            CharSet::utf16le => \"utf16le\",\n            CharSet::utf32 => \"utf32\",\n            CharSet::utf8 => \"utf8\",\n            CharSet::utf8mb4 => \"utf8mb4\",\n        }\n    }\n\n    pub(crate) fn default_collation(&self) -> Collation {\n        match self {\n            CharSet::armscii8 => Collation::armscii8_general_ci,\n            CharSet::ascii => Collation::ascii_general_ci,\n            CharSet::big5 => Collation::big5_chinese_ci,\n            CharSet::binary => Collation::binary,\n            CharSet::cp1250 => Collation::cp1250_general_ci,\n            CharSet::cp1251 => Collation::cp1251_general_ci,\n            CharSet::cp1256 => Collation::cp1256_general_ci,\n            CharSet::cp1257 => Collation::cp1257_general_ci,\n            CharSet::cp850 => Collation::cp850_general_ci,\n            CharSet::cp852 => Collation::cp852_general_ci,\n            CharSet::cp866 => Collation::cp866_general_ci,\n            CharSet::cp932 => Collation::cp932_japanese_ci,\n            CharSet::dec8 => Collation::dec8_swedish_ci,\n            CharSet::eucjpms => Collation::eucjpms_japanese_ci,\n            CharSet::euckr => Collation::euckr_korean_ci,\n            CharSet::gb18030 => Collation::gb18030_chinese_ci,\n            CharSet::gb2312 => Collation::gb2312_chinese_ci,\n            CharSet::gbk => Collation::gbk_chinese_ci,\n            CharSet::geostd8 => Collation::geostd8_general_ci,\n            CharSet::greek => Collation::greek_general_ci,\n            CharSet::hebrew => Collation::hebrew_general_ci,\n            CharSet::hp8 => Collation::hp8_english_ci,\n            CharSet::keybcs2 => Collation::keybcs2_general_ci,\n            CharSet::koi8r => Collation::koi8r_general_ci,\n            CharSet::koi8u => Collation::koi8u_general_ci,\n            CharSet::latin1 => Collation::latin1_swedish_ci,\n            CharSet::latin2 => Collation::latin2_general_ci,\n            CharSet::latin5 => Collation::latin5_turkish_ci,\n            CharSet::latin7 => Collation::latin7_general_ci,\n            CharSet::macce => Collation::macce_general_ci,\n            CharSet::macroman => Collation::macroman_general_ci,\n            CharSet::sjis => Collation::sjis_japanese_ci,\n            CharSet::swe7 => Collation::swe7_swedish_ci,\n            CharSet::tis620 => Collation::tis620_thai_ci,\n            CharSet::ucs2 => Collation::ucs2_general_ci,\n            CharSet::ujis => Collation::ujis_japanese_ci,\n            CharSet::utf16 => Collation::utf16_general_ci,\n            CharSet::utf16le => Collation::utf16le_general_ci,\n            CharSet::utf32 => Collation::utf32_general_ci,\n            CharSet::utf8 => Collation::utf8_unicode_ci,\n            CharSet::utf8mb4 => Collation::utf8mb4_unicode_ci,\n        }\n    }\n}\n\nimpl FromStr for CharSet {\n    type Err = Error;\n\n    fn from_str(char_set: &str) -> Result<Self, Self::Err> {\n        Ok(match char_set {\n            \"armscii8\" => CharSet::armscii8,\n            \"ascii\" => CharSet::ascii,\n            \"big5\" => CharSet::big5,\n            \"binary\" => CharSet::binary,\n            \"cp1250\" => CharSet::cp1250,\n            \"cp1251\" => CharSet::cp1251,\n            \"cp1256\" => CharSet::cp1256,\n            \"cp1257\" => CharSet::cp1257,\n            \"cp850\" => CharSet::cp850,\n            \"cp852\" => CharSet::cp852,\n            \"cp866\" => CharSet::cp866,\n            \"cp932\" => CharSet::cp932,\n            \"dec8\" => CharSet::dec8,\n            \"eucjpms\" => CharSet::eucjpms,\n            \"euckr\" => CharSet::euckr,\n            \"gb18030\" => CharSet::gb18030,\n            \"gb2312\" => CharSet::gb2312,\n            \"gbk\" => CharSet::gbk,\n            \"geostd8\" => CharSet::geostd8,\n            \"greek\" => CharSet::greek,\n            \"hebrew\" => CharSet::hebrew,\n            \"hp8\" => CharSet::hp8,\n            \"keybcs2\" => CharSet::keybcs2,\n            \"koi8r\" => CharSet::koi8r,\n            \"koi8u\" => CharSet::koi8u,\n            \"latin1\" => CharSet::latin1,\n            \"latin2\" => CharSet::latin2,\n            \"latin5\" => CharSet::latin5,\n            \"latin7\" => CharSet::latin7,\n            \"macce\" => CharSet::macce,\n            \"macroman\" => CharSet::macroman,\n            \"sjis\" => CharSet::sjis,\n            \"swe7\" => CharSet::swe7,\n            \"tis620\" => CharSet::tis620,\n            \"ucs2\" => CharSet::ucs2,\n            \"ujis\" => CharSet::ujis,\n            \"utf16\" => CharSet::utf16,\n            \"utf16le\" => CharSet::utf16le,\n            \"utf32\" => CharSet::utf32,\n            \"utf8\" => CharSet::utf8,\n            \"utf8mb4\" => CharSet::utf8mb4,\n\n            _ => {\n                return Err(Error::Configuration(\n                    format!(\"unsupported MySQL charset: {char_set}\").into(),\n                ));\n            }\n        })\n    }\n}\n\n#[derive(Copy, Clone)]\n#[allow(non_camel_case_types)]\n#[repr(u8)]\npub(crate) enum Collation {\n    armscii8_bin = 64,\n    armscii8_general_ci = 32,\n    ascii_bin = 65,\n    ascii_general_ci = 11,\n    big5_bin = 84,\n    big5_chinese_ci = 1,\n    binary = 63,\n    cp1250_bin = 66,\n    cp1250_croatian_ci = 44,\n    cp1250_czech_cs = 34,\n    cp1250_general_ci = 26,\n    cp1250_polish_ci = 99,\n    cp1251_bin = 50,\n    cp1251_bulgarian_ci = 14,\n    cp1251_general_ci = 51,\n    cp1251_general_cs = 52,\n    cp1251_ukrainian_ci = 23,\n    cp1256_bin = 67,\n    cp1256_general_ci = 57,\n    cp1257_bin = 58,\n    cp1257_general_ci = 59,\n    cp1257_lithuanian_ci = 29,\n    cp850_bin = 80,\n    cp850_general_ci = 4,\n    cp852_bin = 81,\n    cp852_general_ci = 40,\n    cp866_bin = 68,\n    cp866_general_ci = 36,\n    cp932_bin = 96,\n    cp932_japanese_ci = 95,\n    dec8_bin = 69,\n    dec8_swedish_ci = 3,\n    eucjpms_bin = 98,\n    eucjpms_japanese_ci = 97,\n    euckr_bin = 85,\n    euckr_korean_ci = 19,\n    gb18030_bin = 249,\n    gb18030_chinese_ci = 248,\n    gb18030_unicode_520_ci = 250,\n    gb2312_bin = 86,\n    gb2312_chinese_ci = 24,\n    gbk_bin = 87,\n    gbk_chinese_ci = 28,\n    geostd8_bin = 93,\n    geostd8_general_ci = 92,\n    greek_bin = 70,\n    greek_general_ci = 25,\n    hebrew_bin = 71,\n    hebrew_general_ci = 16,\n    hp8_bin = 72,\n    hp8_english_ci = 6,\n    keybcs2_bin = 73,\n    keybcs2_general_ci = 37,\n    koi8r_bin = 74,\n    koi8r_general_ci = 7,\n    koi8u_bin = 75,\n    koi8u_general_ci = 22,\n    latin1_bin = 47,\n    latin1_danish_ci = 15,\n    latin1_general_ci = 48,\n    latin1_general_cs = 49,\n    latin1_german1_ci = 5,\n    latin1_german2_ci = 31,\n    latin1_spanish_ci = 94,\n    latin1_swedish_ci = 8,\n    latin2_bin = 77,\n    latin2_croatian_ci = 27,\n    latin2_czech_cs = 2,\n    latin2_general_ci = 9,\n    latin2_hungarian_ci = 21,\n    latin5_bin = 78,\n    latin5_turkish_ci = 30,\n    latin7_bin = 79,\n    latin7_estonian_cs = 20,\n    latin7_general_ci = 41,\n    latin7_general_cs = 42,\n    macce_bin = 43,\n    macce_general_ci = 38,\n    macroman_bin = 53,\n    macroman_general_ci = 39,\n    sjis_bin = 88,\n    sjis_japanese_ci = 13,\n    swe7_bin = 82,\n    swe7_swedish_ci = 10,\n    tis620_bin = 89,\n    tis620_thai_ci = 18,\n    ucs2_bin = 90,\n    ucs2_croatian_ci = 149,\n    ucs2_czech_ci = 138,\n    ucs2_danish_ci = 139,\n    ucs2_esperanto_ci = 145,\n    ucs2_estonian_ci = 134,\n    ucs2_general_ci = 35,\n    ucs2_general_mysql500_ci = 159,\n    ucs2_german2_ci = 148,\n    ucs2_hungarian_ci = 146,\n    ucs2_icelandic_ci = 129,\n    ucs2_latvian_ci = 130,\n    ucs2_lithuanian_ci = 140,\n    ucs2_persian_ci = 144,\n    ucs2_polish_ci = 133,\n    ucs2_roman_ci = 143,\n    ucs2_romanian_ci = 131,\n    ucs2_sinhala_ci = 147,\n    ucs2_slovak_ci = 141,\n    ucs2_slovenian_ci = 132,\n    ucs2_spanish_ci = 135,\n    ucs2_spanish2_ci = 142,\n    ucs2_swedish_ci = 136,\n    ucs2_turkish_ci = 137,\n    ucs2_unicode_520_ci = 150,\n    ucs2_unicode_ci = 128,\n    ucs2_vietnamese_ci = 151,\n    ujis_bin = 91,\n    ujis_japanese_ci = 12,\n    utf16_bin = 55,\n    utf16_croatian_ci = 122,\n    utf16_czech_ci = 111,\n    utf16_danish_ci = 112,\n    utf16_esperanto_ci = 118,\n    utf16_estonian_ci = 107,\n    utf16_general_ci = 54,\n    utf16_german2_ci = 121,\n    utf16_hungarian_ci = 119,\n    utf16_icelandic_ci = 102,\n    utf16_latvian_ci = 103,\n    utf16_lithuanian_ci = 113,\n    utf16_persian_ci = 117,\n    utf16_polish_ci = 106,\n    utf16_roman_ci = 116,\n    utf16_romanian_ci = 104,\n    utf16_sinhala_ci = 120,\n    utf16_slovak_ci = 114,\n    utf16_slovenian_ci = 105,\n    utf16_spanish_ci = 108,\n    utf16_spanish2_ci = 115,\n    utf16_swedish_ci = 109,\n    utf16_turkish_ci = 110,\n    utf16_unicode_520_ci = 123,\n    utf16_unicode_ci = 101,\n    utf16_vietnamese_ci = 124,\n    utf16le_bin = 62,\n    utf16le_general_ci = 56,\n    utf32_bin = 61,\n    utf32_croatian_ci = 181,\n    utf32_czech_ci = 170,\n    utf32_danish_ci = 171,\n    utf32_esperanto_ci = 177,\n    utf32_estonian_ci = 166,\n    utf32_general_ci = 60,\n    utf32_german2_ci = 180,\n    utf32_hungarian_ci = 178,\n    utf32_icelandic_ci = 161,\n    utf32_latvian_ci = 162,\n    utf32_lithuanian_ci = 172,\n    utf32_persian_ci = 176,\n    utf32_polish_ci = 165,\n    utf32_roman_ci = 175,\n    utf32_romanian_ci = 163,\n    utf32_sinhala_ci = 179,\n    utf32_slovak_ci = 173,\n    utf32_slovenian_ci = 164,\n    utf32_spanish_ci = 167,\n    utf32_spanish2_ci = 174,\n    utf32_swedish_ci = 168,\n    utf32_turkish_ci = 169,\n    utf32_unicode_520_ci = 182,\n    utf32_unicode_ci = 160,\n    utf32_vietnamese_ci = 183,\n    utf8_bin = 83,\n    utf8_croatian_ci = 213,\n    utf8_czech_ci = 202,\n    utf8_danish_ci = 203,\n    utf8_esperanto_ci = 209,\n    utf8_estonian_ci = 198,\n    utf8_general_ci = 33,\n    utf8_general_mysql500_ci = 223,\n    utf8_german2_ci = 212,\n    utf8_hungarian_ci = 210,\n    utf8_icelandic_ci = 193,\n    utf8_latvian_ci = 194,\n    utf8_lithuanian_ci = 204,\n    utf8_persian_ci = 208,\n    utf8_polish_ci = 197,\n    utf8_roman_ci = 207,\n    utf8_romanian_ci = 195,\n    utf8_sinhala_ci = 211,\n    utf8_slovak_ci = 205,\n    utf8_slovenian_ci = 196,\n    utf8_spanish_ci = 199,\n    utf8_spanish2_ci = 206,\n    utf8_swedish_ci = 200,\n    utf8_tolower_ci = 76,\n    utf8_turkish_ci = 201,\n    utf8_unicode_520_ci = 214,\n    utf8_unicode_ci = 192,\n    utf8_vietnamese_ci = 215,\n    utf8mb4_0900_ai_ci = 255,\n    utf8mb4_bin = 46,\n    utf8mb4_croatian_ci = 245,\n    utf8mb4_czech_ci = 234,\n    utf8mb4_danish_ci = 235,\n    utf8mb4_esperanto_ci = 241,\n    utf8mb4_estonian_ci = 230,\n    utf8mb4_general_ci = 45,\n    utf8mb4_german2_ci = 244,\n    utf8mb4_hungarian_ci = 242,\n    utf8mb4_icelandic_ci = 225,\n    utf8mb4_latvian_ci = 226,\n    utf8mb4_lithuanian_ci = 236,\n    utf8mb4_persian_ci = 240,\n    utf8mb4_polish_ci = 229,\n    utf8mb4_roman_ci = 239,\n    utf8mb4_romanian_ci = 227,\n    utf8mb4_sinhala_ci = 243,\n    utf8mb4_slovak_ci = 237,\n    utf8mb4_slovenian_ci = 228,\n    utf8mb4_spanish_ci = 231,\n    utf8mb4_spanish2_ci = 238,\n    utf8mb4_swedish_ci = 232,\n    utf8mb4_turkish_ci = 233,\n    utf8mb4_unicode_520_ci = 246,\n    utf8mb4_unicode_ci = 224,\n    utf8mb4_vietnamese_ci = 247,\n}\n\nimpl Collation {\n    pub(crate) fn as_str(&self) -> &'static str {\n        match self {\n            Collation::armscii8_bin => \"armscii8_bin\",\n            Collation::armscii8_general_ci => \"armscii8_general_ci\",\n            Collation::ascii_bin => \"ascii_bin\",\n            Collation::ascii_general_ci => \"ascii_general_ci\",\n            Collation::big5_bin => \"big5_bin\",\n            Collation::big5_chinese_ci => \"big5_chinese_ci\",\n            Collation::binary => \"binary\",\n            Collation::cp1250_bin => \"cp1250_bin\",\n            Collation::cp1250_croatian_ci => \"cp1250_croatian_ci\",\n            Collation::cp1250_czech_cs => \"cp1250_czech_cs\",\n            Collation::cp1250_general_ci => \"cp1250_general_ci\",\n            Collation::cp1250_polish_ci => \"cp1250_polish_ci\",\n            Collation::cp1251_bin => \"cp1251_bin\",\n            Collation::cp1251_bulgarian_ci => \"cp1251_bulgarian_ci\",\n            Collation::cp1251_general_ci => \"cp1251_general_ci\",\n            Collation::cp1251_general_cs => \"cp1251_general_cs\",\n            Collation::cp1251_ukrainian_ci => \"cp1251_ukrainian_ci\",\n            Collation::cp1256_bin => \"cp1256_bin\",\n            Collation::cp1256_general_ci => \"cp1256_general_ci\",\n            Collation::cp1257_bin => \"cp1257_bin\",\n            Collation::cp1257_general_ci => \"cp1257_general_ci\",\n            Collation::cp1257_lithuanian_ci => \"cp1257_lithuanian_ci\",\n            Collation::cp850_bin => \"cp850_bin\",\n            Collation::cp850_general_ci => \"cp850_general_ci\",\n            Collation::cp852_bin => \"cp852_bin\",\n            Collation::cp852_general_ci => \"cp852_general_ci\",\n            Collation::cp866_bin => \"cp866_bin\",\n            Collation::cp866_general_ci => \"cp866_general_ci\",\n            Collation::cp932_bin => \"cp932_bin\",\n            Collation::cp932_japanese_ci => \"cp932_japanese_ci\",\n            Collation::dec8_bin => \"dec8_bin\",\n            Collation::dec8_swedish_ci => \"dec8_swedish_ci\",\n            Collation::eucjpms_bin => \"eucjpms_bin\",\n            Collation::eucjpms_japanese_ci => \"eucjpms_japanese_ci\",\n            Collation::euckr_bin => \"euckr_bin\",\n            Collation::euckr_korean_ci => \"euckr_korean_ci\",\n            Collation::gb18030_bin => \"gb18030_bin\",\n            Collation::gb18030_chinese_ci => \"gb18030_chinese_ci\",\n            Collation::gb18030_unicode_520_ci => \"gb18030_unicode_520_ci\",\n            Collation::gb2312_bin => \"gb2312_bin\",\n            Collation::gb2312_chinese_ci => \"gb2312_chinese_ci\",\n            Collation::gbk_bin => \"gbk_bin\",\n            Collation::gbk_chinese_ci => \"gbk_chinese_ci\",\n            Collation::geostd8_bin => \"geostd8_bin\",\n            Collation::geostd8_general_ci => \"geostd8_general_ci\",\n            Collation::greek_bin => \"greek_bin\",\n            Collation::greek_general_ci => \"greek_general_ci\",\n            Collation::hebrew_bin => \"hebrew_bin\",\n            Collation::hebrew_general_ci => \"hebrew_general_ci\",\n            Collation::hp8_bin => \"hp8_bin\",\n            Collation::hp8_english_ci => \"hp8_english_ci\",\n            Collation::keybcs2_bin => \"keybcs2_bin\",\n            Collation::keybcs2_general_ci => \"keybcs2_general_ci\",\n            Collation::koi8r_bin => \"koi8r_bin\",\n            Collation::koi8r_general_ci => \"koi8r_general_ci\",\n            Collation::koi8u_bin => \"koi8u_bin\",\n            Collation::koi8u_general_ci => \"koi8u_general_ci\",\n            Collation::latin1_bin => \"latin1_bin\",\n            Collation::latin1_danish_ci => \"latin1_danish_ci\",\n            Collation::latin1_general_ci => \"latin1_general_ci\",\n            Collation::latin1_general_cs => \"latin1_general_cs\",\n            Collation::latin1_german1_ci => \"latin1_german1_ci\",\n            Collation::latin1_german2_ci => \"latin1_german2_ci\",\n            Collation::latin1_spanish_ci => \"latin1_spanish_ci\",\n            Collation::latin1_swedish_ci => \"latin1_swedish_ci\",\n            Collation::latin2_bin => \"latin2_bin\",\n            Collation::latin2_croatian_ci => \"latin2_croatian_ci\",\n            Collation::latin2_czech_cs => \"latin2_czech_cs\",\n            Collation::latin2_general_ci => \"latin2_general_ci\",\n            Collation::latin2_hungarian_ci => \"latin2_hungarian_ci\",\n            Collation::latin5_bin => \"latin5_bin\",\n            Collation::latin5_turkish_ci => \"latin5_turkish_ci\",\n            Collation::latin7_bin => \"latin7_bin\",\n            Collation::latin7_estonian_cs => \"latin7_estonian_cs\",\n            Collation::latin7_general_ci => \"latin7_general_ci\",\n            Collation::latin7_general_cs => \"latin7_general_cs\",\n            Collation::macce_bin => \"macce_bin\",\n            Collation::macce_general_ci => \"macce_general_ci\",\n            Collation::macroman_bin => \"macroman_bin\",\n            Collation::macroman_general_ci => \"macroman_general_ci\",\n            Collation::sjis_bin => \"sjis_bin\",\n            Collation::sjis_japanese_ci => \"sjis_japanese_ci\",\n            Collation::swe7_bin => \"swe7_bin\",\n            Collation::swe7_swedish_ci => \"swe7_swedish_ci\",\n            Collation::tis620_bin => \"tis620_bin\",\n            Collation::tis620_thai_ci => \"tis620_thai_ci\",\n            Collation::ucs2_bin => \"ucs2_bin\",\n            Collation::ucs2_croatian_ci => \"ucs2_croatian_ci\",\n            Collation::ucs2_czech_ci => \"ucs2_czech_ci\",\n            Collation::ucs2_danish_ci => \"ucs2_danish_ci\",\n            Collation::ucs2_esperanto_ci => \"ucs2_esperanto_ci\",\n            Collation::ucs2_estonian_ci => \"ucs2_estonian_ci\",\n            Collation::ucs2_general_ci => \"ucs2_general_ci\",\n            Collation::ucs2_general_mysql500_ci => \"ucs2_general_mysql500_ci\",\n            Collation::ucs2_german2_ci => \"ucs2_german2_ci\",\n            Collation::ucs2_hungarian_ci => \"ucs2_hungarian_ci\",\n            Collation::ucs2_icelandic_ci => \"ucs2_icelandic_ci\",\n            Collation::ucs2_latvian_ci => \"ucs2_latvian_ci\",\n            Collation::ucs2_lithuanian_ci => \"ucs2_lithuanian_ci\",\n            Collation::ucs2_persian_ci => \"ucs2_persian_ci\",\n            Collation::ucs2_polish_ci => \"ucs2_polish_ci\",\n            Collation::ucs2_roman_ci => \"ucs2_roman_ci\",\n            Collation::ucs2_romanian_ci => \"ucs2_romanian_ci\",\n            Collation::ucs2_sinhala_ci => \"ucs2_sinhala_ci\",\n            Collation::ucs2_slovak_ci => \"ucs2_slovak_ci\",\n            Collation::ucs2_slovenian_ci => \"ucs2_slovenian_ci\",\n            Collation::ucs2_spanish_ci => \"ucs2_spanish_ci\",\n            Collation::ucs2_spanish2_ci => \"ucs2_spanish2_ci\",\n            Collation::ucs2_swedish_ci => \"ucs2_swedish_ci\",\n            Collation::ucs2_turkish_ci => \"ucs2_turkish_ci\",\n            Collation::ucs2_unicode_520_ci => \"ucs2_unicode_520_ci\",\n            Collation::ucs2_unicode_ci => \"ucs2_unicode_ci\",\n            Collation::ucs2_vietnamese_ci => \"ucs2_vietnamese_ci\",\n            Collation::ujis_bin => \"ujis_bin\",\n            Collation::ujis_japanese_ci => \"ujis_japanese_ci\",\n            Collation::utf16_bin => \"utf16_bin\",\n            Collation::utf16_croatian_ci => \"utf16_croatian_ci\",\n            Collation::utf16_czech_ci => \"utf16_czech_ci\",\n            Collation::utf16_danish_ci => \"utf16_danish_ci\",\n            Collation::utf16_esperanto_ci => \"utf16_esperanto_ci\",\n            Collation::utf16_estonian_ci => \"utf16_estonian_ci\",\n            Collation::utf16_general_ci => \"utf16_general_ci\",\n            Collation::utf16_german2_ci => \"utf16_german2_ci\",\n            Collation::utf16_hungarian_ci => \"utf16_hungarian_ci\",\n            Collation::utf16_icelandic_ci => \"utf16_icelandic_ci\",\n            Collation::utf16_latvian_ci => \"utf16_latvian_ci\",\n            Collation::utf16_lithuanian_ci => \"utf16_lithuanian_ci\",\n            Collation::utf16_persian_ci => \"utf16_persian_ci\",\n            Collation::utf16_polish_ci => \"utf16_polish_ci\",\n            Collation::utf16_roman_ci => \"utf16_roman_ci\",\n            Collation::utf16_romanian_ci => \"utf16_romanian_ci\",\n            Collation::utf16_sinhala_ci => \"utf16_sinhala_ci\",\n            Collation::utf16_slovak_ci => \"utf16_slovak_ci\",\n            Collation::utf16_slovenian_ci => \"utf16_slovenian_ci\",\n            Collation::utf16_spanish_ci => \"utf16_spanish_ci\",\n            Collation::utf16_spanish2_ci => \"utf16_spanish2_ci\",\n            Collation::utf16_swedish_ci => \"utf16_swedish_ci\",\n            Collation::utf16_turkish_ci => \"utf16_turkish_ci\",\n            Collation::utf16_unicode_520_ci => \"utf16_unicode_520_ci\",\n            Collation::utf16_unicode_ci => \"utf16_unicode_ci\",\n            Collation::utf16_vietnamese_ci => \"utf16_vietnamese_ci\",\n            Collation::utf16le_bin => \"utf16le_bin\",\n            Collation::utf16le_general_ci => \"utf16le_general_ci\",\n            Collation::utf32_bin => \"utf32_bin\",\n            Collation::utf32_croatian_ci => \"utf32_croatian_ci\",\n            Collation::utf32_czech_ci => \"utf32_czech_ci\",\n            Collation::utf32_danish_ci => \"utf32_danish_ci\",\n            Collation::utf32_esperanto_ci => \"utf32_esperanto_ci\",\n            Collation::utf32_estonian_ci => \"utf32_estonian_ci\",\n            Collation::utf32_general_ci => \"utf32_general_ci\",\n            Collation::utf32_german2_ci => \"utf32_german2_ci\",\n            Collation::utf32_hungarian_ci => \"utf32_hungarian_ci\",\n            Collation::utf32_icelandic_ci => \"utf32_icelandic_ci\",\n            Collation::utf32_latvian_ci => \"utf32_latvian_ci\",\n            Collation::utf32_lithuanian_ci => \"utf32_lithuanian_ci\",\n            Collation::utf32_persian_ci => \"utf32_persian_ci\",\n            Collation::utf32_polish_ci => \"utf32_polish_ci\",\n            Collation::utf32_roman_ci => \"utf32_roman_ci\",\n            Collation::utf32_romanian_ci => \"utf32_romanian_ci\",\n            Collation::utf32_sinhala_ci => \"utf32_sinhala_ci\",\n            Collation::utf32_slovak_ci => \"utf32_slovak_ci\",\n            Collation::utf32_slovenian_ci => \"utf32_slovenian_ci\",\n            Collation::utf32_spanish_ci => \"utf32_spanish_ci\",\n            Collation::utf32_spanish2_ci => \"utf32_spanish2_ci\",\n            Collation::utf32_swedish_ci => \"utf32_swedish_ci\",\n            Collation::utf32_turkish_ci => \"utf32_turkish_ci\",\n            Collation::utf32_unicode_520_ci => \"utf32_unicode_520_ci\",\n            Collation::utf32_unicode_ci => \"utf32_unicode_ci\",\n            Collation::utf32_vietnamese_ci => \"utf32_vietnamese_ci\",\n            Collation::utf8_bin => \"utf8_bin\",\n            Collation::utf8_croatian_ci => \"utf8_croatian_ci\",\n            Collation::utf8_czech_ci => \"utf8_czech_ci\",\n            Collation::utf8_danish_ci => \"utf8_danish_ci\",\n            Collation::utf8_esperanto_ci => \"utf8_esperanto_ci\",\n            Collation::utf8_estonian_ci => \"utf8_estonian_ci\",\n            Collation::utf8_general_ci => \"utf8_general_ci\",\n            Collation::utf8_general_mysql500_ci => \"utf8_general_mysql500_ci\",\n            Collation::utf8_german2_ci => \"utf8_german2_ci\",\n            Collation::utf8_hungarian_ci => \"utf8_hungarian_ci\",\n            Collation::utf8_icelandic_ci => \"utf8_icelandic_ci\",\n            Collation::utf8_latvian_ci => \"utf8_latvian_ci\",\n            Collation::utf8_lithuanian_ci => \"utf8_lithuanian_ci\",\n            Collation::utf8_persian_ci => \"utf8_persian_ci\",\n            Collation::utf8_polish_ci => \"utf8_polish_ci\",\n            Collation::utf8_roman_ci => \"utf8_roman_ci\",\n            Collation::utf8_romanian_ci => \"utf8_romanian_ci\",\n            Collation::utf8_sinhala_ci => \"utf8_sinhala_ci\",\n            Collation::utf8_slovak_ci => \"utf8_slovak_ci\",\n            Collation::utf8_slovenian_ci => \"utf8_slovenian_ci\",\n            Collation::utf8_spanish_ci => \"utf8_spanish_ci\",\n            Collation::utf8_spanish2_ci => \"utf8_spanish2_ci\",\n            Collation::utf8_swedish_ci => \"utf8_swedish_ci\",\n            Collation::utf8_tolower_ci => \"utf8_tolower_ci\",\n            Collation::utf8_turkish_ci => \"utf8_turkish_ci\",\n            Collation::utf8_unicode_520_ci => \"utf8_unicode_520_ci\",\n            Collation::utf8_unicode_ci => \"utf8_unicode_ci\",\n            Collation::utf8_vietnamese_ci => \"utf8_vietnamese_ci\",\n            Collation::utf8mb4_0900_ai_ci => \"utf8mb4_0900_ai_ci\",\n            Collation::utf8mb4_bin => \"utf8mb4_bin\",\n            Collation::utf8mb4_croatian_ci => \"utf8mb4_croatian_ci\",\n            Collation::utf8mb4_czech_ci => \"utf8mb4_czech_ci\",\n            Collation::utf8mb4_danish_ci => \"utf8mb4_danish_ci\",\n            Collation::utf8mb4_esperanto_ci => \"utf8mb4_esperanto_ci\",\n            Collation::utf8mb4_estonian_ci => \"utf8mb4_estonian_ci\",\n            Collation::utf8mb4_general_ci => \"utf8mb4_general_ci\",\n            Collation::utf8mb4_german2_ci => \"utf8mb4_german2_ci\",\n            Collation::utf8mb4_hungarian_ci => \"utf8mb4_hungarian_ci\",\n            Collation::utf8mb4_icelandic_ci => \"utf8mb4_icelandic_ci\",\n            Collation::utf8mb4_latvian_ci => \"utf8mb4_latvian_ci\",\n            Collation::utf8mb4_lithuanian_ci => \"utf8mb4_lithuanian_ci\",\n            Collation::utf8mb4_persian_ci => \"utf8mb4_persian_ci\",\n            Collation::utf8mb4_polish_ci => \"utf8mb4_polish_ci\",\n            Collation::utf8mb4_roman_ci => \"utf8mb4_roman_ci\",\n            Collation::utf8mb4_romanian_ci => \"utf8mb4_romanian_ci\",\n            Collation::utf8mb4_sinhala_ci => \"utf8mb4_sinhala_ci\",\n            Collation::utf8mb4_slovak_ci => \"utf8mb4_slovak_ci\",\n            Collation::utf8mb4_slovenian_ci => \"utf8mb4_slovenian_ci\",\n            Collation::utf8mb4_spanish_ci => \"utf8mb4_spanish_ci\",\n            Collation::utf8mb4_spanish2_ci => \"utf8mb4_spanish2_ci\",\n            Collation::utf8mb4_swedish_ci => \"utf8mb4_swedish_ci\",\n            Collation::utf8mb4_turkish_ci => \"utf8mb4_turkish_ci\",\n            Collation::utf8mb4_unicode_520_ci => \"utf8mb4_unicode_520_ci\",\n            Collation::utf8mb4_unicode_ci => \"utf8mb4_unicode_ci\",\n            Collation::utf8mb4_vietnamese_ci => \"utf8mb4_vietnamese_ci\",\n        }\n    }\n}\n\n// Handshake packet have only 1 byte for collation_id.\n// So we can't use collations with ID > 255.\nimpl FromStr for Collation {\n    type Err = Error;\n\n    fn from_str(collation: &str) -> Result<Self, Self::Err> {\n        Ok(match collation {\n            \"big5_chinese_ci\" => Collation::big5_chinese_ci,\n            \"swe7_swedish_ci\" => Collation::swe7_swedish_ci,\n            \"utf16_unicode_ci\" => Collation::utf16_unicode_ci,\n            \"utf16_icelandic_ci\" => Collation::utf16_icelandic_ci,\n            \"utf16_latvian_ci\" => Collation::utf16_latvian_ci,\n            \"utf16_romanian_ci\" => Collation::utf16_romanian_ci,\n            \"utf16_slovenian_ci\" => Collation::utf16_slovenian_ci,\n            \"utf16_polish_ci\" => Collation::utf16_polish_ci,\n            \"utf16_estonian_ci\" => Collation::utf16_estonian_ci,\n            \"utf16_spanish_ci\" => Collation::utf16_spanish_ci,\n            \"utf16_swedish_ci\" => Collation::utf16_swedish_ci,\n            \"ascii_general_ci\" => Collation::ascii_general_ci,\n            \"utf16_turkish_ci\" => Collation::utf16_turkish_ci,\n            \"utf16_czech_ci\" => Collation::utf16_czech_ci,\n            \"utf16_danish_ci\" => Collation::utf16_danish_ci,\n            \"utf16_lithuanian_ci\" => Collation::utf16_lithuanian_ci,\n            \"utf16_slovak_ci\" => Collation::utf16_slovak_ci,\n            \"utf16_spanish2_ci\" => Collation::utf16_spanish2_ci,\n            \"utf16_roman_ci\" => Collation::utf16_roman_ci,\n            \"utf16_persian_ci\" => Collation::utf16_persian_ci,\n            \"utf16_esperanto_ci\" => Collation::utf16_esperanto_ci,\n            \"utf16_hungarian_ci\" => Collation::utf16_hungarian_ci,\n            \"ujis_japanese_ci\" => Collation::ujis_japanese_ci,\n            \"utf16_sinhala_ci\" => Collation::utf16_sinhala_ci,\n            \"utf16_german2_ci\" => Collation::utf16_german2_ci,\n            \"utf16_croatian_ci\" => Collation::utf16_croatian_ci,\n            \"utf16_unicode_520_ci\" => Collation::utf16_unicode_520_ci,\n            \"utf16_vietnamese_ci\" => Collation::utf16_vietnamese_ci,\n            \"ucs2_unicode_ci\" => Collation::ucs2_unicode_ci,\n            \"ucs2_icelandic_ci\" => Collation::ucs2_icelandic_ci,\n            \"sjis_japanese_ci\" => Collation::sjis_japanese_ci,\n            \"ucs2_latvian_ci\" => Collation::ucs2_latvian_ci,\n            \"ucs2_romanian_ci\" => Collation::ucs2_romanian_ci,\n            \"ucs2_slovenian_ci\" => Collation::ucs2_slovenian_ci,\n            \"ucs2_polish_ci\" => Collation::ucs2_polish_ci,\n            \"ucs2_estonian_ci\" => Collation::ucs2_estonian_ci,\n            \"ucs2_spanish_ci\" => Collation::ucs2_spanish_ci,\n            \"ucs2_swedish_ci\" => Collation::ucs2_swedish_ci,\n            \"ucs2_turkish_ci\" => Collation::ucs2_turkish_ci,\n            \"ucs2_czech_ci\" => Collation::ucs2_czech_ci,\n            \"ucs2_danish_ci\" => Collation::ucs2_danish_ci,\n            \"cp1251_bulgarian_ci\" => Collation::cp1251_bulgarian_ci,\n            \"ucs2_lithuanian_ci\" => Collation::ucs2_lithuanian_ci,\n            \"ucs2_slovak_ci\" => Collation::ucs2_slovak_ci,\n            \"ucs2_spanish2_ci\" => Collation::ucs2_spanish2_ci,\n            \"ucs2_roman_ci\" => Collation::ucs2_roman_ci,\n            \"ucs2_persian_ci\" => Collation::ucs2_persian_ci,\n            \"ucs2_esperanto_ci\" => Collation::ucs2_esperanto_ci,\n            \"ucs2_hungarian_ci\" => Collation::ucs2_hungarian_ci,\n            \"ucs2_sinhala_ci\" => Collation::ucs2_sinhala_ci,\n            \"ucs2_german2_ci\" => Collation::ucs2_german2_ci,\n            \"ucs2_croatian_ci\" => Collation::ucs2_croatian_ci,\n            \"latin1_danish_ci\" => Collation::latin1_danish_ci,\n            \"ucs2_unicode_520_ci\" => Collation::ucs2_unicode_520_ci,\n            \"ucs2_vietnamese_ci\" => Collation::ucs2_vietnamese_ci,\n            \"ucs2_general_mysql500_ci\" => Collation::ucs2_general_mysql500_ci,\n            \"hebrew_general_ci\" => Collation::hebrew_general_ci,\n            \"utf32_unicode_ci\" => Collation::utf32_unicode_ci,\n            \"utf32_icelandic_ci\" => Collation::utf32_icelandic_ci,\n            \"utf32_latvian_ci\" => Collation::utf32_latvian_ci,\n            \"utf32_romanian_ci\" => Collation::utf32_romanian_ci,\n            \"utf32_slovenian_ci\" => Collation::utf32_slovenian_ci,\n            \"utf32_polish_ci\" => Collation::utf32_polish_ci,\n            \"utf32_estonian_ci\" => Collation::utf32_estonian_ci,\n            \"utf32_spanish_ci\" => Collation::utf32_spanish_ci,\n            \"utf32_swedish_ci\" => Collation::utf32_swedish_ci,\n            \"utf32_turkish_ci\" => Collation::utf32_turkish_ci,\n            \"utf32_czech_ci\" => Collation::utf32_czech_ci,\n            \"utf32_danish_ci\" => Collation::utf32_danish_ci,\n            \"utf32_lithuanian_ci\" => Collation::utf32_lithuanian_ci,\n            \"utf32_slovak_ci\" => Collation::utf32_slovak_ci,\n            \"utf32_spanish2_ci\" => Collation::utf32_spanish2_ci,\n            \"utf32_roman_ci\" => Collation::utf32_roman_ci,\n            \"utf32_persian_ci\" => Collation::utf32_persian_ci,\n            \"utf32_esperanto_ci\" => Collation::utf32_esperanto_ci,\n            \"utf32_hungarian_ci\" => Collation::utf32_hungarian_ci,\n            \"utf32_sinhala_ci\" => Collation::utf32_sinhala_ci,\n            \"tis620_thai_ci\" => Collation::tis620_thai_ci,\n            \"utf32_german2_ci\" => Collation::utf32_german2_ci,\n            \"utf32_croatian_ci\" => Collation::utf32_croatian_ci,\n            \"utf32_unicode_520_ci\" => Collation::utf32_unicode_520_ci,\n            \"utf32_vietnamese_ci\" => Collation::utf32_vietnamese_ci,\n            \"euckr_korean_ci\" => Collation::euckr_korean_ci,\n            \"utf8_unicode_ci\" => Collation::utf8_unicode_ci,\n            \"utf8_icelandic_ci\" => Collation::utf8_icelandic_ci,\n            \"utf8_latvian_ci\" => Collation::utf8_latvian_ci,\n            \"utf8_romanian_ci\" => Collation::utf8_romanian_ci,\n            \"utf8_slovenian_ci\" => Collation::utf8_slovenian_ci,\n            \"utf8_polish_ci\" => Collation::utf8_polish_ci,\n            \"utf8_estonian_ci\" => Collation::utf8_estonian_ci,\n            \"utf8_spanish_ci\" => Collation::utf8_spanish_ci,\n            \"latin2_czech_cs\" => Collation::latin2_czech_cs,\n            \"latin7_estonian_cs\" => Collation::latin7_estonian_cs,\n            \"utf8_swedish_ci\" => Collation::utf8_swedish_ci,\n            \"utf8_turkish_ci\" => Collation::utf8_turkish_ci,\n            \"utf8_czech_ci\" => Collation::utf8_czech_ci,\n            \"utf8_danish_ci\" => Collation::utf8_danish_ci,\n            \"utf8_lithuanian_ci\" => Collation::utf8_lithuanian_ci,\n            \"utf8_slovak_ci\" => Collation::utf8_slovak_ci,\n            \"utf8_spanish2_ci\" => Collation::utf8_spanish2_ci,\n            \"utf8_roman_ci\" => Collation::utf8_roman_ci,\n            \"utf8_persian_ci\" => Collation::utf8_persian_ci,\n            \"utf8_esperanto_ci\" => Collation::utf8_esperanto_ci,\n            \"latin2_hungarian_ci\" => Collation::latin2_hungarian_ci,\n            \"utf8_hungarian_ci\" => Collation::utf8_hungarian_ci,\n            \"utf8_sinhala_ci\" => Collation::utf8_sinhala_ci,\n            \"utf8_german2_ci\" => Collation::utf8_german2_ci,\n            \"utf8_croatian_ci\" => Collation::utf8_croatian_ci,\n            \"utf8_unicode_520_ci\" => Collation::utf8_unicode_520_ci,\n            \"utf8_vietnamese_ci\" => Collation::utf8_vietnamese_ci,\n            \"koi8u_general_ci\" => Collation::koi8u_general_ci,\n            \"utf8_general_mysql500_ci\" => Collation::utf8_general_mysql500_ci,\n            \"utf8mb4_unicode_ci\" => Collation::utf8mb4_unicode_ci,\n            \"utf8mb4_icelandic_ci\" => Collation::utf8mb4_icelandic_ci,\n            \"utf8mb4_latvian_ci\" => Collation::utf8mb4_latvian_ci,\n            \"utf8mb4_romanian_ci\" => Collation::utf8mb4_romanian_ci,\n            \"utf8mb4_slovenian_ci\" => Collation::utf8mb4_slovenian_ci,\n            \"utf8mb4_polish_ci\" => Collation::utf8mb4_polish_ci,\n            \"cp1251_ukrainian_ci\" => Collation::cp1251_ukrainian_ci,\n            \"utf8mb4_estonian_ci\" => Collation::utf8mb4_estonian_ci,\n            \"utf8mb4_spanish_ci\" => Collation::utf8mb4_spanish_ci,\n            \"utf8mb4_swedish_ci\" => Collation::utf8mb4_swedish_ci,\n            \"utf8mb4_turkish_ci\" => Collation::utf8mb4_turkish_ci,\n            \"utf8mb4_czech_ci\" => Collation::utf8mb4_czech_ci,\n            \"utf8mb4_danish_ci\" => Collation::utf8mb4_danish_ci,\n            \"utf8mb4_lithuanian_ci\" => Collation::utf8mb4_lithuanian_ci,\n            \"utf8mb4_slovak_ci\" => Collation::utf8mb4_slovak_ci,\n            \"utf8mb4_spanish2_ci\" => Collation::utf8mb4_spanish2_ci,\n            \"utf8mb4_roman_ci\" => Collation::utf8mb4_roman_ci,\n            \"gb2312_chinese_ci\" => Collation::gb2312_chinese_ci,\n            \"utf8mb4_persian_ci\" => Collation::utf8mb4_persian_ci,\n            \"utf8mb4_esperanto_ci\" => Collation::utf8mb4_esperanto_ci,\n            \"utf8mb4_hungarian_ci\" => Collation::utf8mb4_hungarian_ci,\n            \"utf8mb4_sinhala_ci\" => Collation::utf8mb4_sinhala_ci,\n            \"utf8mb4_german2_ci\" => Collation::utf8mb4_german2_ci,\n            \"utf8mb4_croatian_ci\" => Collation::utf8mb4_croatian_ci,\n            \"utf8mb4_unicode_520_ci\" => Collation::utf8mb4_unicode_520_ci,\n            \"utf8mb4_vietnamese_ci\" => Collation::utf8mb4_vietnamese_ci,\n            \"gb18030_chinese_ci\" => Collation::gb18030_chinese_ci,\n            \"gb18030_bin\" => Collation::gb18030_bin,\n            \"greek_general_ci\" => Collation::greek_general_ci,\n            \"gb18030_unicode_520_ci\" => Collation::gb18030_unicode_520_ci,\n            \"utf8mb4_0900_ai_ci\" => Collation::utf8mb4_0900_ai_ci,\n            \"cp1250_general_ci\" => Collation::cp1250_general_ci,\n            \"latin2_croatian_ci\" => Collation::latin2_croatian_ci,\n            \"gbk_chinese_ci\" => Collation::gbk_chinese_ci,\n            \"cp1257_lithuanian_ci\" => Collation::cp1257_lithuanian_ci,\n            \"dec8_swedish_ci\" => Collation::dec8_swedish_ci,\n            \"latin5_turkish_ci\" => Collation::latin5_turkish_ci,\n            \"latin1_german2_ci\" => Collation::latin1_german2_ci,\n            \"armscii8_general_ci\" => Collation::armscii8_general_ci,\n            \"utf8_general_ci\" => Collation::utf8_general_ci,\n            \"cp1250_czech_cs\" => Collation::cp1250_czech_cs,\n            \"ucs2_general_ci\" => Collation::ucs2_general_ci,\n            \"cp866_general_ci\" => Collation::cp866_general_ci,\n            \"keybcs2_general_ci\" => Collation::keybcs2_general_ci,\n            \"macce_general_ci\" => Collation::macce_general_ci,\n            \"macroman_general_ci\" => Collation::macroman_general_ci,\n            \"cp850_general_ci\" => Collation::cp850_general_ci,\n            \"cp852_general_ci\" => Collation::cp852_general_ci,\n            \"latin7_general_ci\" => Collation::latin7_general_ci,\n            \"latin7_general_cs\" => Collation::latin7_general_cs,\n            \"macce_bin\" => Collation::macce_bin,\n            \"cp1250_croatian_ci\" => Collation::cp1250_croatian_ci,\n            \"utf8mb4_general_ci\" => Collation::utf8mb4_general_ci,\n            \"utf8mb4_bin\" => Collation::utf8mb4_bin,\n            \"latin1_bin\" => Collation::latin1_bin,\n            \"latin1_general_ci\" => Collation::latin1_general_ci,\n            \"latin1_general_cs\" => Collation::latin1_general_cs,\n            \"latin1_german1_ci\" => Collation::latin1_german1_ci,\n            \"cp1251_bin\" => Collation::cp1251_bin,\n            \"cp1251_general_ci\" => Collation::cp1251_general_ci,\n            \"cp1251_general_cs\" => Collation::cp1251_general_cs,\n            \"macroman_bin\" => Collation::macroman_bin,\n            \"utf16_general_ci\" => Collation::utf16_general_ci,\n            \"utf16_bin\" => Collation::utf16_bin,\n            \"utf16le_general_ci\" => Collation::utf16le_general_ci,\n            \"cp1256_general_ci\" => Collation::cp1256_general_ci,\n            \"cp1257_bin\" => Collation::cp1257_bin,\n            \"cp1257_general_ci\" => Collation::cp1257_general_ci,\n            \"hp8_english_ci\" => Collation::hp8_english_ci,\n            \"utf32_general_ci\" => Collation::utf32_general_ci,\n            \"utf32_bin\" => Collation::utf32_bin,\n            \"utf16le_bin\" => Collation::utf16le_bin,\n            \"binary\" => Collation::binary,\n            \"armscii8_bin\" => Collation::armscii8_bin,\n            \"ascii_bin\" => Collation::ascii_bin,\n            \"cp1250_bin\" => Collation::cp1250_bin,\n            \"cp1256_bin\" => Collation::cp1256_bin,\n            \"cp866_bin\" => Collation::cp866_bin,\n            \"dec8_bin\" => Collation::dec8_bin,\n            \"koi8r_general_ci\" => Collation::koi8r_general_ci,\n            \"greek_bin\" => Collation::greek_bin,\n            \"hebrew_bin\" => Collation::hebrew_bin,\n            \"hp8_bin\" => Collation::hp8_bin,\n            \"keybcs2_bin\" => Collation::keybcs2_bin,\n            \"koi8r_bin\" => Collation::koi8r_bin,\n            \"koi8u_bin\" => Collation::koi8u_bin,\n            \"utf8_tolower_ci\" => Collation::utf8_tolower_ci,\n            \"latin2_bin\" => Collation::latin2_bin,\n            \"latin5_bin\" => Collation::latin5_bin,\n            \"latin7_bin\" => Collation::latin7_bin,\n            \"latin1_swedish_ci\" => Collation::latin1_swedish_ci,\n            \"cp850_bin\" => Collation::cp850_bin,\n            \"cp852_bin\" => Collation::cp852_bin,\n            \"swe7_bin\" => Collation::swe7_bin,\n            \"utf8_bin\" => Collation::utf8_bin,\n            \"big5_bin\" => Collation::big5_bin,\n            \"euckr_bin\" => Collation::euckr_bin,\n            \"gb2312_bin\" => Collation::gb2312_bin,\n            \"gbk_bin\" => Collation::gbk_bin,\n            \"sjis_bin\" => Collation::sjis_bin,\n            \"tis620_bin\" => Collation::tis620_bin,\n            \"latin2_general_ci\" => Collation::latin2_general_ci,\n            \"ucs2_bin\" => Collation::ucs2_bin,\n            \"ujis_bin\" => Collation::ujis_bin,\n            \"geostd8_general_ci\" => Collation::geostd8_general_ci,\n            \"geostd8_bin\" => Collation::geostd8_bin,\n            \"latin1_spanish_ci\" => Collation::latin1_spanish_ci,\n            \"cp932_japanese_ci\" => Collation::cp932_japanese_ci,\n            \"cp932_bin\" => Collation::cp932_bin,\n            \"eucjpms_japanese_ci\" => Collation::eucjpms_japanese_ci,\n            \"eucjpms_bin\" => Collation::eucjpms_bin,\n            \"cp1250_polish_ci\" => Collation::cp1250_polish_ci,\n\n            _ => {\n                return Err(Error::Configuration(\n                    format!(\"unsupported MySQL collation: {collation}\").into(),\n                ));\n            }\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/io/buf.rs",
    "content": "use bytes::{Buf, Bytes};\n\nuse crate::error::Error;\nuse crate::io::BufExt;\n\npub trait MySqlBufExt: Buf {\n    // Read a length-encoded integer.\n    // NOTE: 0xfb or NULL is only returned for binary value encoding to indicate NULL.\n    // NOTE: 0xff is only returned during a result set to indicate ERR.\n    // <https://dev.mysql.com/doc/internals/en/integer.html#packet-Protocol::LengthEncodedInteger>\n    fn get_uint_lenenc(&mut self) -> u64;\n\n    // Read a length-encoded string.\n    fn get_str_lenenc(&mut self) -> Result<String, Error>;\n\n    // Read a length-encoded byte sequence.\n    fn get_bytes_lenenc(&mut self) -> Bytes;\n}\n\nimpl MySqlBufExt for Bytes {\n    fn get_uint_lenenc(&mut self) -> u64 {\n        match self.get_u8() {\n            0xfc => u64::from(self.get_u16_le()),\n            0xfd => self.get_uint_le(3),\n            0xfe => self.get_u64_le(),\n\n            v => u64::from(v),\n        }\n    }\n\n    fn get_str_lenenc(&mut self) -> Result<String, Error> {\n        let size = self.get_uint_lenenc();\n        self.get_str(size as usize)\n    }\n\n    fn get_bytes_lenenc(&mut self) -> Bytes {\n        let size = self.get_uint_lenenc();\n        self.split_to(size as usize)\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/io/buf_mut.rs",
    "content": "use bytes::BufMut;\n\npub trait MySqlBufMutExt: BufMut {\n    fn put_uint_lenenc(&mut self, v: u64);\n\n    fn put_str_lenenc(&mut self, v: &str);\n\n    fn put_bytes_lenenc(&mut self, v: &[u8]);\n}\n\nimpl MySqlBufMutExt for Vec<u8> {\n    fn put_uint_lenenc(&mut self, v: u64) {\n        // https://dev.mysql.com/doc/internals/en/integer.html\n        // https://mariadb.com/kb/en/library/protocol-data-types/#length-encoded-integers\n\n        if v < 251 {\n            self.push(v as u8);\n        } else if v < 0x1_00_00 {\n            self.push(0xfc);\n            self.extend((v as u16).to_le_bytes());\n        } else if v < 0x1_00_00_00 {\n            self.push(0xfd);\n            self.extend(&(v as u32).to_le_bytes()[..3]);\n        } else {\n            self.push(0xfe);\n            self.extend(v.to_le_bytes());\n        }\n    }\n\n    fn put_str_lenenc(&mut self, v: &str) {\n        self.put_bytes_lenenc(v.as_bytes());\n    }\n\n    fn put_bytes_lenenc(&mut self, v: &[u8]) {\n        self.put_uint_lenenc(v.len() as u64);\n        self.extend(v);\n    }\n}\n\n#[test]\nfn test_encodes_int_lenenc_u8() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_uint_lenenc(0xFA_u64);\n\n    assert_eq!(&buf[..], b\"\\xFA\");\n}\n\n#[test]\nfn test_encodes_int_lenenc_u16() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_uint_lenenc(std::u16::MAX as u64);\n\n    assert_eq!(&buf[..], b\"\\xFC\\xFF\\xFF\");\n}\n\n#[test]\nfn test_encodes_int_lenenc_u24() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_uint_lenenc(0xFF_FF_FF_u64);\n\n    assert_eq!(&buf[..], b\"\\xFD\\xFF\\xFF\\xFF\");\n}\n\n#[test]\nfn test_encodes_int_lenenc_u64() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_uint_lenenc(std::u64::MAX);\n\n    assert_eq!(&buf[..], b\"\\xFE\\xFF\\xFF\\xFF\\xFF\\xFF\\xFF\\xFF\\xFF\");\n}\n\n#[test]\nfn test_encodes_int_lenenc_fb() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_uint_lenenc(0xFB_u64);\n\n    assert_eq!(&buf[..], b\"\\xFC\\xFB\\x00\");\n}\n\n#[test]\nfn test_encodes_int_lenenc_fc() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_uint_lenenc(0xFC_u64);\n\n    assert_eq!(&buf[..], b\"\\xFC\\xFC\\x00\");\n}\n\n#[test]\nfn test_encodes_int_lenenc_fd() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_uint_lenenc(0xFD_u64);\n\n    assert_eq!(&buf[..], b\"\\xFC\\xFD\\x00\");\n}\n\n#[test]\nfn test_encodes_int_lenenc_fe() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_uint_lenenc(0xFE_u64);\n\n    assert_eq!(&buf[..], b\"\\xFC\\xFE\\x00\");\n}\n\n#[test]\nfn test_encodes_int_lenenc_ff() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_uint_lenenc(0xFF_u64);\n\n    assert_eq!(&buf[..], b\"\\xFC\\xFF\\x00\");\n}\n\n#[test]\nfn test_encodes_string_lenenc() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_str_lenenc(\"random_string\");\n\n    assert_eq!(&buf[..], b\"\\x0Drandom_string\");\n}\n\n#[test]\nfn test_encodes_byte_lenenc() {\n    let mut buf = Vec::with_capacity(1024);\n    buf.put_bytes_lenenc(b\"random_string\");\n\n    assert_eq!(&buf[..], b\"\\x0Drandom_string\");\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/io/mod.rs",
    "content": "mod buf;\nmod buf_mut;\n\npub use buf::MySqlBufExt;\npub use buf_mut::MySqlBufMutExt;\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/mod.rs",
    "content": "//! **MySQL** database driver.\n\npub mod collation;\npub mod io;\npub mod protocol;\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/auth.rs",
    "content": "use std::str::FromStr;\n\nuse crate::err_protocol;\nuse crate::error::Error;\n\n#[derive(Debug, Copy, Clone, PartialEq, Eq)]\n#[allow(clippy::enum_variant_names)]\npub enum AuthPlugin {\n    MySqlClearPassword,\n    MySqlNativePassword,\n    CachingSha2Password,\n    Sha256Password,\n}\n\nimpl AuthPlugin {\n    pub(crate) fn name(self) -> &'static str {\n        match self {\n            AuthPlugin::MySqlClearPassword => \"mysql_clear_password\",\n            AuthPlugin::MySqlNativePassword => \"mysql_native_password\",\n            AuthPlugin::CachingSha2Password => \"caching_sha2_password\",\n            AuthPlugin::Sha256Password => \"sha256_password\",\n        }\n    }\n}\n\nimpl FromStr for AuthPlugin {\n    type Err = Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        match s {\n            \"mysql_clear_password\" => Ok(AuthPlugin::MySqlClearPassword),\n            \"mysql_native_password\" => Ok(AuthPlugin::MySqlNativePassword),\n            \"caching_sha2_password\" => Ok(AuthPlugin::CachingSha2Password),\n            \"sha256_password\" => Ok(AuthPlugin::Sha256Password),\n\n            _ => Err(err_protocol!(\"unknown authentication plugin: {}\", s)),\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/capabilities.rs",
    "content": "// https://dev.mysql.com/doc/dev/mysql-server/8.0.12/group__group__cs__capabilities__flags.html\n// https://mariadb.com/kb/en/library/connection/#capabilities\nbitflags::bitflags! {\n    #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)]\n    pub struct Capabilities: u64 {\n        // [MariaDB] MySQL compatibility\n        const MYSQL = 1;\n\n        // [*] Send found rows instead of affected rows in EOF_Packet.\n        const FOUND_ROWS = 2;\n\n        // Get all column flags.\n        const LONG_FLAG = 4;\n\n        // [*] Database (schema) name can be specified on connect in Handshake Response Packet.\n        const CONNECT_WITH_DB = 8;\n\n        // Don't allow database.table.column\n        const NO_SCHEMA = 16;\n\n        // [*] Compression protocol supported\n        const COMPRESS = 32;\n\n        // Special handling of ODBC behavior.\n        const ODBC = 64;\n\n        // Can use LOAD DATA LOCAL\n        const LOCAL_FILES = 128;\n\n        // [*] Ignore spaces before '('\n        const IGNORE_SPACE = 256;\n\n        // [*] New 4.1+ protocol\n        const PROTOCOL_41 = 512;\n\n        // This is an interactive client\n        const INTERACTIVE = 1024;\n\n        // Use SSL encryption for this session\n        const SSL = 2048;\n\n        // Client knows about transactions\n        const TRANSACTIONS = 8192;\n\n        // 4.1+ authentication\n        const SECURE_CONNECTION = (1 << 15);\n\n        // Enable/disable multi-statement support for COM_QUERY *and* COM_STMT_PREPARE\n        const MULTI_STATEMENTS = (1 << 16);\n\n        // Enable/disable multi-results for COM_QUERY\n        const MULTI_RESULTS = (1 << 17);\n\n        // Enable/disable multi-results for COM_STMT_PREPARE\n        const PS_MULTI_RESULTS = (1 << 18);\n\n        // Client supports plugin authentication\n        const PLUGIN_AUTH = (1 << 19);\n\n        // Client supports connection attributes\n        const CONNECT_ATTRS = (1 << 20);\n\n        // Enable authentication response packet to be larger than 255 bytes.\n        const PLUGIN_AUTH_LENENC_DATA = (1 << 21);\n\n        // Don't close the connection for a user account with expired password.\n        const CAN_HANDLE_EXPIRED_PASSWORDS = (1 << 22);\n\n        // Capable of handling server state change information.\n        const SESSION_TRACK = (1 << 23);\n\n        // Client no longer needs EOF_Packet and will use OK_Packet instead.\n        const DEPRECATE_EOF = (1 << 24);\n\n        // Support ZSTD protocol compression\n        const ZSTD_COMPRESSION_ALGORITHM = (1 << 26);\n\n        // Verify server certificate\n        const SSL_VERIFY_SERVER_CERT = (1 << 30);\n\n        // The client can handle optional metadata information in the resultset\n        const OPTIONAL_RESULTSET_METADATA = (1 << 25);\n\n        // Don't reset the options after an unsuccessful connect\n        const REMEMBER_OPTIONS = (1 << 31);\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/connect/auth_switch.rs",
    "content": "use bytes::{Buf, BufMut, Bytes};\n\nuse crate::err_protocol;\nuse crate::error::Error;\nuse crate::io::{BufExt, BufMutExt, Decode, Encode};\nuse crate::mysql::protocol::auth::AuthPlugin;\nuse crate::mysql::protocol::Capabilities;\n\n// https://dev.mysql.com/doc/dev/mysql-server/8.0.12/page_protocol_connection_phase_packets_protocol_auth_switch_request.html\n\n#[derive(Debug)]\npub struct AuthSwitchRequest {\n    pub plugin: AuthPlugin,\n    pub data: Bytes,\n}\n\nimpl Decode<'_> for AuthSwitchRequest {\n    fn decode_with(mut buf: Bytes, _: ()) -> Result<Self, Error> {\n        let header = buf.get_u8();\n        if header != 0xfe {\n            return Err(err_protocol!(\n                \"expected 0xfe (AUTH_SWITCH) but found 0x{:x}\",\n                header\n            ));\n        }\n\n        let plugin = buf.get_str_nul()?.parse()?;\n\n        // See: https://github.com/mysql/mysql-server/blob/ea7d2e2d16ac03afdd9cb72a972a95981107bf51/sql/auth/sha2_password.cc#L942\n        if buf.len() != 21 {\n            return Err(err_protocol!(\n                \"expected 21 bytes but found {} bytes\",\n                buf.len()\n            ));\n        }\n        let data = buf.get_bytes(20);\n        buf.advance(1); // NUL-terminator\n\n        Ok(Self { plugin, data })\n    }\n}\n\nimpl Encode<'_, ()> for AuthSwitchRequest {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: ()) {\n        buf.put_u8(0xfe);\n        buf.put_str_nul(self.plugin.name());\n        buf.extend(&self.data);\n    }\n}\n\n#[derive(Debug)]\npub struct AuthSwitchResponse(pub Vec<u8>);\n\nimpl Encode<'_, Capabilities> for AuthSwitchResponse {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: Capabilities) {\n        buf.extend_from_slice(&self.0);\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/connect/handshake.rs",
    "content": "use bytes::buf::Chain;\nuse bytes::{Buf, BufMut, Bytes};\n\nuse crate::error::Error;\nuse crate::io::{BufExt, BufMutExt, Decode, Encode};\nuse crate::mysql::protocol::auth::AuthPlugin;\nuse crate::mysql::protocol::response::Status;\nuse crate::mysql::protocol::Capabilities;\n\n// https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake\n// https://mariadb.com/kb/en/connection/#initial-handshake-packet\n\n#[derive(Debug)]\npub struct Handshake {\n    #[allow(unused)]\n    pub protocol_version: u8,\n    pub server_version: String,\n    #[allow(unused)]\n    pub connection_id: u32,\n    pub server_capabilities: Capabilities,\n    #[allow(unused)]\n    pub server_default_collation: u8,\n    #[allow(unused)]\n    pub status: Status,\n    pub auth_plugin: Option<AuthPlugin>,\n    pub auth_plugin_data: Chain<Bytes, Bytes>,\n}\n\nimpl Decode<'_> for Handshake {\n    fn decode_with(mut buf: Bytes, _: ()) -> Result<Self, Error> {\n        let protocol_version = buf.get_u8(); // int<1>\n        let server_version = buf.get_str_nul()?; // string<NUL>\n        let connection_id = buf.get_u32_le(); // int<4>\n        let auth_plugin_data_1 = buf.get_bytes(8); // string<8>\n\n        buf.advance(1); // reserved: string<1>\n\n        let capabilities_1 = buf.get_u16_le(); // int<2>\n        let mut capabilities = Capabilities::from_bits_truncate(capabilities_1.into());\n\n        let collation = buf.get_u8(); // int<1>\n        let status = Status::from_bits_truncate(buf.get_u16_le());\n\n        let capabilities_2 = buf.get_u16_le(); // int<2>\n        capabilities |= Capabilities::from_bits_truncate(((capabilities_2 as u32) << 16).into());\n\n        let auth_plugin_data_len = if capabilities.contains(Capabilities::PLUGIN_AUTH) {\n            buf.get_u8()\n        } else {\n            buf.advance(1); // int<1>\n            0\n        };\n\n        buf.advance(6); // reserved: string<6>\n\n        if capabilities.contains(Capabilities::MYSQL) {\n            buf.advance(4); // reserved: string<4>\n        } else {\n            let capabilities_3 = buf.get_u32_le(); // int<4>\n            capabilities |= Capabilities::from_bits_truncate((capabilities_3 as u64) << 32);\n        }\n\n        let auth_plugin_data_2 = if capabilities.contains(Capabilities::SECURE_CONNECTION) {\n            let len = ((auth_plugin_data_len as isize) - 9).max(12) as usize;\n            let v = buf.get_bytes(len);\n            buf.advance(1); // NUL-terminator\n\n            v\n        } else {\n            Bytes::new()\n        };\n\n        let auth_plugin = if capabilities.contains(Capabilities::PLUGIN_AUTH) {\n            Some(buf.get_str_nul()?.parse()?)\n        } else {\n            None\n        };\n\n        Ok(Self {\n            protocol_version,\n            server_version,\n            connection_id,\n            server_default_collation: collation,\n            status,\n            server_capabilities: capabilities,\n            auth_plugin,\n            auth_plugin_data: auth_plugin_data_1.chain(auth_plugin_data_2),\n        })\n    }\n}\n\nimpl Encode<'_, ()> for Handshake {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: ()) {\n        buf.put_u8(self.protocol_version);\n        buf.put_str_nul(&self.server_version);\n        buf.put_u32_le(self.connection_id);\n        buf.put_slice(self.auth_plugin_data.first_ref());\n        buf.put_u8(0x00);\n        buf.put_u16_le((self.server_capabilities.bits() & 0x0000_FFFF) as u16);\n        buf.put_u8(self.server_default_collation);\n        buf.put_u16_le(self.status.bits());\n        buf.put_u16_le(((self.server_capabilities.bits() & 0xFFFF_0000) >> 16) as u16);\n\n        if self.server_capabilities.contains(Capabilities::PLUGIN_AUTH) {\n            buf.put_u8((self.auth_plugin_data.last_ref().len() + 8 + 1) as u8);\n        } else {\n            buf.put_u8(0);\n        }\n\n        buf.put_slice(&[0_u8; 10][..]);\n\n        if self\n            .server_capabilities\n            .contains(Capabilities::SECURE_CONNECTION)\n        {\n            buf.put_slice(self.auth_plugin_data.last_ref());\n            buf.put_u8(0);\n        }\n\n        if self.server_capabilities.contains(Capabilities::PLUGIN_AUTH) {\n            if let Some(auth_plugin) = self.auth_plugin {\n                buf.put_str_nul(auth_plugin.name());\n            }\n        }\n    }\n}\n\n#[test]\n#[allow(clippy::unwrap_used)]\nfn test_decode_handshake_mysql_8_0_18() {\n    const HANDSHAKE_MYSQL_8_0_18: &[u8] = b\"\\n8.0.18\\x00\\x19\\x00\\x00\\x00\\x114aB0c\\x06g\\x00\\xff\\xff\\xff\\x02\\x00\\xff\\xc7\\x15\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00tL\\x03s\\x0f[4\\rl4. \\x00caching_sha2_password\\x00\";\n\n    let mut p = Handshake::decode(HANDSHAKE_MYSQL_8_0_18.into()).unwrap();\n\n    assert_eq!(p.protocol_version, 10);\n\n    p.server_capabilities.toggle(\n        Capabilities::MYSQL\n            | Capabilities::FOUND_ROWS\n            | Capabilities::LONG_FLAG\n            | Capabilities::CONNECT_WITH_DB\n            | Capabilities::NO_SCHEMA\n            | Capabilities::COMPRESS\n            | Capabilities::ODBC\n            | Capabilities::LOCAL_FILES\n            | Capabilities::IGNORE_SPACE\n            | Capabilities::PROTOCOL_41\n            | Capabilities::INTERACTIVE\n            | Capabilities::SSL\n            | Capabilities::TRANSACTIONS\n            | Capabilities::SECURE_CONNECTION\n            | Capabilities::MULTI_STATEMENTS\n            | Capabilities::MULTI_RESULTS\n            | Capabilities::PS_MULTI_RESULTS\n            | Capabilities::PLUGIN_AUTH\n            | Capabilities::CONNECT_ATTRS\n            | Capabilities::PLUGIN_AUTH_LENENC_DATA\n            | Capabilities::CAN_HANDLE_EXPIRED_PASSWORDS\n            | Capabilities::SESSION_TRACK\n            | Capabilities::DEPRECATE_EOF\n            | Capabilities::ZSTD_COMPRESSION_ALGORITHM\n            | Capabilities::SSL_VERIFY_SERVER_CERT\n            | Capabilities::OPTIONAL_RESULTSET_METADATA\n            | Capabilities::REMEMBER_OPTIONS,\n    );\n\n    assert!(p.server_capabilities.is_empty());\n\n    assert_eq!(p.server_default_collation, 255);\n    assert!(p.status.contains(Status::SERVER_STATUS_AUTOCOMMIT));\n\n    assert!(matches!(\n        p.auth_plugin,\n        Some(AuthPlugin::CachingSha2Password)\n    ));\n\n    assert_eq!(\n        &*p.auth_plugin_data.into_iter().collect::<Vec<_>>(),\n        &[17, 52, 97, 66, 48, 99, 6, 103, 116, 76, 3, 115, 15, 91, 52, 13, 108, 52, 46, 32,]\n    );\n}\n\n#[test]\n#[allow(clippy::unwrap_used)]\nfn test_decode_handshake_mariadb_10_4_7() {\n    const HANDSHAKE_MARIA_DB_10_4_7: &[u8] = b\"\\n5.5.5-10.4.7-MariaDB-1:10.4.7+maria~bionic\\x00\\x0b\\x00\\x00\\x00t6L\\\\j\\\"dS\\x00\\xfe\\xf7\\x08\\x02\\x00\\xff\\x81\\x15\\x00\\x00\\x00\\x00\\x00\\x00\\x07\\x00\\x00\\x00U14Oph9\\\"<H5n\\x00mysql_native_password\\x00\";\n\n    let mut p = Handshake::decode(HANDSHAKE_MARIA_DB_10_4_7.into()).unwrap();\n\n    assert_eq!(p.protocol_version, 10);\n\n    assert_eq!(\n        &*p.server_version,\n        \"5.5.5-10.4.7-MariaDB-1:10.4.7+maria~bionic\"\n    );\n\n    p.server_capabilities.toggle(\n        Capabilities::FOUND_ROWS\n            | Capabilities::LONG_FLAG\n            | Capabilities::CONNECT_WITH_DB\n            | Capabilities::NO_SCHEMA\n            | Capabilities::COMPRESS\n            | Capabilities::ODBC\n            | Capabilities::LOCAL_FILES\n            | Capabilities::IGNORE_SPACE\n            | Capabilities::PROTOCOL_41\n            | Capabilities::INTERACTIVE\n            | Capabilities::TRANSACTIONS\n            | Capabilities::SECURE_CONNECTION\n            | Capabilities::MULTI_STATEMENTS\n            | Capabilities::MULTI_RESULTS\n            | Capabilities::PS_MULTI_RESULTS\n            | Capabilities::PLUGIN_AUTH\n            | Capabilities::CONNECT_ATTRS\n            | Capabilities::PLUGIN_AUTH_LENENC_DATA\n            | Capabilities::CAN_HANDLE_EXPIRED_PASSWORDS\n            | Capabilities::SESSION_TRACK\n            | Capabilities::DEPRECATE_EOF\n            | Capabilities::REMEMBER_OPTIONS,\n    );\n\n    assert!(p.server_capabilities.is_empty());\n\n    assert_eq!(p.server_default_collation, 8);\n    assert!(p.status.contains(Status::SERVER_STATUS_AUTOCOMMIT));\n    assert!(matches!(\n        p.auth_plugin,\n        Some(AuthPlugin::MySqlNativePassword)\n    ));\n\n    assert_eq!(\n        &*p.auth_plugin_data.into_iter().collect::<Vec<_>>(),\n        &[116, 54, 76, 92, 106, 34, 100, 83, 85, 49, 52, 79, 112, 104, 57, 34, 60, 72, 53, 110,]\n    );\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/connect/handshake_response.rs",
    "content": "use std::str::FromStr;\n\nuse bytes::{Buf, Bytes};\n\nuse crate::error::Error;\nuse crate::io::{BufExt, BufMutExt, Decode, Encode};\nuse crate::mysql::io::{MySqlBufExt, MySqlBufMutExt};\nuse crate::mysql::protocol::auth::AuthPlugin;\nuse crate::mysql::protocol::connect::ssl_request::SslRequest;\nuse crate::mysql::protocol::Capabilities;\n\n// https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::HandshakeResponse\n// https://mariadb.com/kb/en/connection/#client-handshake-response\n\n#[derive(Debug)]\npub struct HandshakeResponse {\n    pub database: Option<String>,\n\n    /// Max size of a command packet that the client wants to send to the server\n    pub max_packet_size: u32,\n\n    /// Default collation for the connection\n    pub collation: u8,\n\n    /// Name of the SQL account which client wants to log in\n    pub username: String,\n\n    /// Authentication method used by the client\n    pub auth_plugin: Option<AuthPlugin>,\n\n    /// Opaque authentication response\n    pub auth_response: Option<Bytes>,\n}\n\nimpl Encode<'_, Capabilities> for HandshakeResponse {\n    fn encode_with(&self, buf: &mut Vec<u8>, mut capabilities: Capabilities) {\n        if self.auth_plugin.is_none() {\n            // ensure PLUGIN_AUTH is set *only* if we have a defined plugin\n            capabilities.remove(Capabilities::PLUGIN_AUTH);\n        }\n\n        // NOTE: Half of this packet is identical to the SSL Request packet\n        SslRequest {\n            max_packet_size: self.max_packet_size,\n            collation: self.collation,\n        }\n        .encode_with(buf, capabilities);\n\n        buf.put_str_nul(&self.username);\n\n        if capabilities.contains(Capabilities::PLUGIN_AUTH_LENENC_DATA) {\n            if let Some(response) = &self.auth_response {\n                buf.put_bytes_lenenc(response);\n            } else {\n                buf.put_bytes_lenenc(&[]);\n            }\n        } else if capabilities.contains(Capabilities::SECURE_CONNECTION) {\n            if let Some(response) = &self.auth_response {\n                buf.push(response.len() as u8);\n                buf.extend(response);\n            } else {\n                buf.push(0);\n            }\n        } else {\n            buf.push(0);\n        }\n\n        if capabilities.contains(Capabilities::CONNECT_WITH_DB) {\n            if let Some(database) = &self.database {\n                buf.put_str_nul(database);\n            } else {\n                buf.push(0);\n            }\n        }\n\n        if capabilities.contains(Capabilities::PLUGIN_AUTH) {\n            if let Some(plugin) = &self.auth_plugin {\n                buf.put_str_nul(plugin.name());\n            } else {\n                buf.push(0);\n            }\n        }\n    }\n}\n\nimpl Decode<'_, &mut Capabilities> for HandshakeResponse {\n    fn decode_with(mut buf: Bytes, server_capabilities: &mut Capabilities) -> Result<Self, Error> {\n        let mut capabilities = buf.get_u32_le() as u64;\n        let max_packet_size = buf.get_u32_le();\n        let collation = buf.get_u8();\n        buf.advance(19);\n\n        let partial_cap = Capabilities::from_bits_truncate(capabilities);\n\n        if partial_cap.contains(Capabilities::MYSQL) {\n            // reserved: string<4>\n            buf.advance(4);\n        } else {\n            capabilities += (buf.get_u32_le() as u64) << 32;\n        }\n\n        let partial_cap = Capabilities::from_bits_truncate(capabilities);\n        if partial_cap.contains(Capabilities::SSL) && buf.is_empty() {\n            return Ok(HandshakeResponse {\n                collation,\n                max_packet_size,\n                username: \"\".to_string(),\n                auth_response: None,\n                auth_plugin: None,\n                database: None,\n            });\n        }\n        let username = buf.get_str_nul()?;\n\n        let auth_response = if partial_cap.contains(Capabilities::PLUGIN_AUTH_LENENC_DATA) {\n            Some(buf.get_bytes_lenenc())\n        } else if partial_cap.contains(Capabilities::SECURE_CONNECTION) {\n            let len = buf.get_u8();\n            Some(buf.get_bytes(len as usize))\n        } else {\n            Some(buf.get_bytes_nul()?)\n        };\n\n        let database = if partial_cap.contains(Capabilities::CONNECT_WITH_DB) {\n            Some(buf.get_str_nul()?)\n        } else {\n            None\n        };\n\n        let auth_plugin: Option<AuthPlugin> = if partial_cap.contains(Capabilities::PLUGIN_AUTH) {\n            Some(AuthPlugin::from_str(&buf.get_str_nul()?)?)\n        } else {\n            None\n        };\n\n        *server_capabilities &= Capabilities::from_bits_truncate(capabilities);\n\n        Ok(HandshakeResponse {\n            collation,\n            max_packet_size,\n            username,\n            auth_response,\n            auth_plugin,\n            database,\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/connect/mod.rs",
    "content": "//! Connection Phase\n//!\n//! <https://dev.mysql.com/doc/internals/en/connection-phase.html>\n\nmod auth_switch;\nmod handshake;\nmod handshake_response;\nmod ssl_request;\n\npub use auth_switch::{AuthSwitchRequest, AuthSwitchResponse};\npub use handshake::Handshake;\npub use handshake_response::HandshakeResponse;\npub use ssl_request::SslRequest;\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/connect/ssl_request.rs",
    "content": "use crate::io::Encode;\nuse crate::mysql::protocol::Capabilities;\n\n// https://dev.mysql.com/doc/dev/mysql-server/8.0.12/page_protocol_connection_phase_packets_protocol_handshake_response.html\n// https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::SSLRequest\n\n#[derive(Debug)]\npub struct SslRequest {\n    pub max_packet_size: u32,\n    pub collation: u8,\n}\n\nimpl Encode<'_, Capabilities> for SslRequest {\n    fn encode_with(&self, buf: &mut Vec<u8>, capabilities: Capabilities) {\n        buf.extend((capabilities.bits() as u32).to_le_bytes());\n        buf.extend(self.max_packet_size.to_le_bytes());\n        buf.push(self.collation);\n\n        // reserved: string<19>\n        buf.extend([0_u8; 19]);\n\n        if capabilities.contains(Capabilities::MYSQL) {\n            // reserved: string<4>\n            buf.extend([0_u8; 4]);\n        } else {\n            // extended client capabilities (MariaDB-specified): int<4>\n            buf.extend(((capabilities.bits() >> 32) as u32).to_le_bytes());\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/mod.rs",
    "content": "pub mod auth;\npub mod capabilities;\npub mod connect;\npub mod packet;\npub mod response;\npub mod row;\npub mod text;\n\npub use capabilities::Capabilities;\npub use packet::Packet;\npub use row::Row;\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/packet.rs",
    "content": "use std::ops::{Deref, DerefMut};\n\nuse bytes::Bytes;\n\nuse crate::error::Error;\nuse crate::io::{Decode, Encode};\nuse crate::mysql::protocol::response::{EofPacket, OkPacket};\nuse crate::mysql::protocol::Capabilities;\n\n#[derive(Debug)]\npub struct Packet<T>(pub T);\n\nimpl<'en, 'stream, T> Encode<'stream, (Capabilities, &'stream mut u8)> for Packet<T>\nwhere\n    T: Encode<'en, Capabilities>,\n{\n    fn encode_with(\n        &self,\n        buf: &mut Vec<u8>,\n        (capabilities, sequence_id): (Capabilities, &'stream mut u8),\n    ) {\n        // reserve space to write the prefixed length\n        let offset = buf.len();\n        buf.extend([0_u8; 4]);\n\n        // encode the payload\n        self.0.encode_with(buf, capabilities);\n\n        // determine the length of the encoded payload\n        // and write to our reserved space\n        let len = buf.len() - offset - 4;\n        let header = &mut buf[offset..];\n\n        // FIXME: Support larger packets\n        assert!(len < 0xFF_FF_FF);\n\n        header[..4].copy_from_slice(&(len as u32).to_le_bytes());\n        header[3] = *sequence_id;\n\n        *sequence_id = sequence_id.wrapping_add(1);\n    }\n}\n\nimpl Packet<Bytes> {\n    pub(crate) fn decode<'de, T>(self) -> Result<T, Error>\n    where\n        T: Decode<'de, ()>,\n    {\n        self.decode_with(())\n    }\n\n    pub(crate) fn decode_with<'de, T, C>(self, context: C) -> Result<T, Error>\n    where\n        T: Decode<'de, C>,\n    {\n        T::decode_with(self.0, context)\n    }\n\n    pub(crate) fn ok(self) -> Result<OkPacket, Error> {\n        self.decode()\n    }\n\n    pub(crate) fn eof(self, capabilities: Capabilities) -> Result<EofPacket, Error> {\n        if capabilities.contains(Capabilities::DEPRECATE_EOF) {\n            let ok = self.ok()?;\n\n            Ok(EofPacket {\n                warnings: ok.warnings,\n                status: ok.status,\n            })\n        } else {\n            self.decode_with(capabilities)\n        }\n    }\n}\n\nimpl Deref for Packet<Bytes> {\n    type Target = Bytes;\n\n    fn deref(&self) -> &Bytes {\n        &self.0\n    }\n}\n\nimpl DerefMut for Packet<Bytes> {\n    fn deref_mut(&mut self) -> &mut Bytes {\n        &mut self.0\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/response/eof.rs",
    "content": "use bytes::{Buf, Bytes};\n\nuse crate::err_protocol;\nuse crate::error::Error;\nuse crate::io::Decode;\nuse crate::mysql::protocol::response::Status;\nuse crate::mysql::protocol::Capabilities;\n\n/// Marks the end of a result set, returning status and warnings.\n///\n/// # Note\n///\n/// The EOF packet is deprecated as of MySQL 5.7.5. SQLx only uses this packet for MySQL\n/// prior MySQL versions.\n#[derive(Debug)]\npub struct EofPacket {\n    pub warnings: u16,\n    pub status: Status,\n}\n\nimpl Decode<'_, Capabilities> for EofPacket {\n    fn decode_with(mut buf: Bytes, _: Capabilities) -> Result<Self, Error> {\n        let header = buf.get_u8();\n        if header != 0xfe {\n            return Err(err_protocol!(\n                \"expected 0xfe (EOF_Packet) but found 0x{:x}\",\n                header\n            ));\n        }\n\n        let warnings = buf.get_u16_le();\n        let status = Status::from_bits_truncate(buf.get_u16_le());\n\n        Ok(Self { status, warnings })\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/response/err.rs",
    "content": "use bytes::{Buf, BufMut, Bytes};\n\nuse crate::err_protocol;\nuse crate::error::Error;\nuse crate::io::{BufExt, Decode, Encode};\nuse crate::mysql::protocol::Capabilities;\n\n// https://dev.mysql.com/doc/dev/mysql-server/8.0.12/page_protocol_basic_err_packet.html\n// https://mariadb.com/kb/en/err_packet/\n\n/// Indicates that an error occurred.\n#[derive(Debug)]\npub struct ErrPacket {\n    pub error_code: u16,\n    pub sql_state: Option<String>,\n    pub error_message: String,\n}\n\nimpl Decode<'_, Capabilities> for ErrPacket {\n    fn decode_with(mut buf: Bytes, capabilities: Capabilities) -> Result<Self, Error> {\n        let header = buf.get_u8();\n        if header != 0xff {\n            return Err(err_protocol!(\n                \"expected 0xff (ERR_Packet) but found 0x{:x}\",\n                header\n            ));\n        }\n\n        let error_code = buf.get_u16_le();\n        let mut sql_state = None;\n\n        if capabilities.contains(Capabilities::PROTOCOL_41) {\n            // If the next byte is '#' then we have a SQL STATE\n            if buf.first() == Some(&0x23) {\n                buf.advance(1);\n                sql_state = Some(buf.get_str(5)?);\n            }\n        }\n\n        let error_message = buf.get_str(buf.len())?;\n\n        Ok(Self {\n            error_code,\n            sql_state,\n            error_message,\n        })\n    }\n}\n\nimpl Encode<'_, ()> for ErrPacket {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: ()) {\n        buf.put_u8(0xff);\n        buf.put_u16_le(self.error_code);\n        buf.extend_from_slice(self.error_message.as_bytes())\n        //TODO: sql_state\n    }\n}\n\n#[test]\n#[allow(clippy::unwrap_used)]\nfn test_decode_err_packet_out_of_order() {\n    const ERR_PACKETS_OUT_OF_ORDER: &[u8] = b\"\\xff\\x84\\x04Got packets out of order\";\n\n    let p =\n        ErrPacket::decode_with(ERR_PACKETS_OUT_OF_ORDER.into(), Capabilities::PROTOCOL_41).unwrap();\n\n    assert_eq!(&p.error_message, \"Got packets out of order\");\n    assert_eq!(p.error_code, 1156);\n    assert_eq!(p.sql_state, None);\n}\n\n#[test]\n#[allow(clippy::unwrap_used)]\nfn test_decode_err_packet_unknown_database() {\n    const ERR_HANDSHAKE_UNKNOWN_DB: &[u8] = b\"\\xff\\x19\\x04#42000Unknown database \\'unknown\\'\";\n\n    let p =\n        ErrPacket::decode_with(ERR_HANDSHAKE_UNKNOWN_DB.into(), Capabilities::PROTOCOL_41).unwrap();\n\n    assert_eq!(p.error_code, 1049);\n    assert_eq!(p.sql_state.as_deref(), Some(\"42000\"));\n    assert_eq!(&p.error_message, \"Unknown database \\'unknown\\'\");\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/response/mod.rs",
    "content": "//! Generic Response Packets\n//!\n//! <https://dev.mysql.com/doc/internals/en/generic-response-packets.html>\n//! <https://mariadb.com/kb/en/4-server-response-packets/>\n\nmod eof;\nmod err;\nmod ok;\nmod status;\n\npub use eof::EofPacket;\npub use err::ErrPacket;\npub use ok::OkPacket;\npub use status::Status;\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/response/ok.rs",
    "content": "use bytes::{Buf, BufMut, Bytes};\n\nuse crate::err_protocol;\nuse crate::error::Error;\nuse crate::io::{Decode, Encode};\nuse crate::mysql::io::{MySqlBufExt, MySqlBufMutExt};\nuse crate::mysql::protocol::response::Status;\n\n/// Indicates successful completion of a previous command sent by the client.\n#[derive(Debug)]\npub struct OkPacket {\n    pub affected_rows: u64,\n    pub last_insert_id: u64,\n    pub status: Status,\n    pub warnings: u16,\n}\n\nimpl Decode<'_> for OkPacket {\n    fn decode_with(mut buf: Bytes, _: ()) -> Result<Self, Error> {\n        let header = buf.get_u8();\n        if header != 0 && header != 0xfe {\n            return Err(err_protocol!(\n                \"expected 0x00 or 0xfe (OK_Packet) but found 0x{:02x}\",\n                header\n            ));\n        }\n\n        let affected_rows = buf.get_uint_lenenc();\n        let last_insert_id = buf.get_uint_lenenc();\n        let status = Status::from_bits_truncate(buf.get_u16_le());\n        let warnings = buf.get_u16_le();\n\n        Ok(Self {\n            affected_rows,\n            last_insert_id,\n            status,\n            warnings,\n        })\n    }\n}\n\nimpl Encode<'_, ()> for OkPacket {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: ()) {\n        buf.put_u8(0);\n        buf.put_uint_lenenc(self.affected_rows);\n        buf.put_uint_lenenc(self.last_insert_id);\n        buf.put_u16_le(self.status.bits());\n        buf.put_u16_le(self.warnings);\n    }\n}\n\n#[test]\n#[allow(clippy::unwrap_used)]\nfn test_decode_ok_packet() {\n    const DATA: &[u8] = b\"\\x00\\x00\\x00\\x02@\\x00\\x00\";\n\n    let p = OkPacket::decode(DATA.into()).unwrap();\n\n    assert_eq!(p.affected_rows, 0);\n    assert_eq!(p.last_insert_id, 0);\n    assert_eq!(p.warnings, 0);\n    assert!(p.status.contains(Status::SERVER_STATUS_AUTOCOMMIT));\n    assert!(p.status.contains(Status::SERVER_SESSION_STATE_CHANGED));\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/response/status.rs",
    "content": "// https://dev.mysql.com/doc/dev/mysql-server/8.0.12/mysql__com_8h.html#a1d854e841086925be1883e4d7b4e8cad\n// https://mariadb.com/kb/en/library/mariadb-connectorc-types-and-definitions/#server-status\nbitflags::bitflags! {\n    #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)]\n    pub struct Status: u16 {\n        // Is raised when a multi-statement transaction has been started, either explicitly,\n        // by means of BEGIN or COMMIT AND CHAIN, or implicitly, by the first\n        // transactional statement, when autocommit=off.\n        const SERVER_STATUS_IN_TRANS = 1;\n\n        // Autocommit mode is set\n        const SERVER_STATUS_AUTOCOMMIT = 2;\n\n        // Multi query - next query exists.\n        const SERVER_MORE_RESULTS_EXISTS = 8;\n\n        const SERVER_QUERY_NO_GOOD_INDEX_USED = 16;\n        const SERVER_QUERY_NO_INDEX_USED = 32;\n\n        // When using COM_STMT_FETCH, indicate that current cursor still has result\n        const SERVER_STATUS_CURSOR_EXISTS = 64;\n\n        // When using COM_STMT_FETCH, indicate that current cursor has finished to send results\n        const SERVER_STATUS_LAST_ROW_SENT = 128;\n\n        // Database has been dropped\n        const SERVER_STATUS_DB_DROPPED = (1 << 8);\n\n        // Current escape mode is \"no backslash escape\"\n        const SERVER_STATUS_NO_BACKSLASH_ESCAPES = (1 << 9);\n\n        // A DDL change did have an impact on an existing PREPARE (an automatic\n        // re-prepare has been executed)\n        const SERVER_STATUS_METADATA_CHANGED = (1 << 10);\n\n        // Last statement took more than the time value specified\n        // in server variable long_query_time.\n        const SERVER_QUERY_WAS_SLOW = (1 << 11);\n\n        // This result-set contain stored procedure output parameter.\n        const SERVER_PS_OUT_PARAMS = (1 << 12);\n\n        // Current transaction is a read-only transaction.\n        const SERVER_STATUS_IN_TRANS_READONLY = (1 << 13);\n\n        // This status flag, when on, implies that one of the state information has changed\n        // on the server because of the execution of the last statement.\n        const SERVER_SESSION_STATE_CHANGED = (1 << 14);\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/row.rs",
    "content": "use std::ops::Range;\n\nuse bytes::Bytes;\n\n#[derive(Debug)]\npub struct Row {\n    pub(crate) storage: Bytes,\n    pub(crate) values: Vec<Option<Range<usize>>>,\n}\n\nimpl Row {\n    pub(crate) fn get(&self, index: usize) -> Option<&[u8]> {\n        self.values[index]\n            .as_ref()\n            .map(|col| &self.storage[col.start..col.end])\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/text/column.rs",
    "content": "use std::str::from_utf8;\n\nuse bitflags::bitflags;\nuse bytes::{Buf, Bytes};\n\nuse crate::err_protocol;\nuse crate::error::Error;\nuse crate::io::Decode;\nuse crate::mysql::io::MySqlBufExt;\nuse crate::mysql::protocol::Capabilities;\n\n// https://dev.mysql.com/doc/dev/mysql-server/8.0.12/group__group__cs__column__definition__flags.html\n\nbitflags! {\n    #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)]\n    pub struct ColumnFlags: u16 {\n        /// Field can't be `NULL`.\n        const NOT_NULL = 1;\n\n        /// Field is part of a primary key.\n        const PRIMARY_KEY = 2;\n\n        /// Field is part of a unique key.\n        const UNIQUE_KEY = 4;\n\n        /// Field is part of a multi-part unique or primary key.\n        const MULTIPLE_KEY = 8;\n\n        /// Field is a blob.\n        const BLOB = 16;\n\n        /// Field is unsigned.\n        const UNSIGNED = 32;\n\n        /// Field is zero filled.\n        const ZEROFILL = 64;\n\n        /// Field is binary.\n        const BINARY = 128;\n\n        /// Field is an enumeration.\n        const ENUM = 256;\n\n        /// Field is an auto-incement field.\n        const AUTO_INCREMENT = 512;\n\n        /// Field is a timestamp.\n        const TIMESTAMP = 1024;\n\n        /// Field is a set.\n        const SET = 2048;\n\n        /// Field does not have a default value.\n        const NO_DEFAULT_VALUE = 4096;\n\n        /// Field is set to NOW on UPDATE.\n        const ON_UPDATE_NOW = 8192;\n\n        /// Field is a number.\n        const NUM = 32768;\n    }\n}\n\n// https://dev.mysql.com/doc/internals/en/com-query-response.html#column-type\n\n#[derive(Debug, Copy, Clone, PartialEq, Eq)]\n#[repr(u8)]\npub enum ColumnType {\n    Decimal = 0x00,\n    Tiny = 0x01,\n    Short = 0x02,\n    Long = 0x03,\n    Float = 0x04,\n    Double = 0x05,\n    Null = 0x06,\n    Timestamp = 0x07,\n    LongLong = 0x08,\n    Int24 = 0x09,\n    Date = 0x0a,\n    Time = 0x0b,\n    Datetime = 0x0c,\n    Year = 0x0d,\n    VarChar = 0x0f,\n    Bit = 0x10,\n    Json = 0xf5,\n    NewDecimal = 0xf6,\n    Enum = 0xf7,\n    Set = 0xf8,\n    TinyBlob = 0xf9,\n    MediumBlob = 0xfa,\n    LongBlob = 0xfb,\n    Blob = 0xfc,\n    VarString = 0xfd,\n    String = 0xfe,\n    Geometry = 0xff,\n}\n\n// https://dev.mysql.com/doc/dev/mysql-server/8.0.12/page_protocol_com_query_response_text_resultset_column_definition.html\n// https://mariadb.com/kb/en/resultset/#column-definition-packet\n// https://dev.mysql.com/doc/internals/en/com-query-response.html#packet-Protocol::ColumnDefinition41\n\n#[derive(Debug)]\npub struct ColumnDefinition {\n    #[allow(unused)]\n    catalog: Bytes,\n    #[allow(unused)]\n    schema: Bytes,\n    #[allow(unused)]\n    table_alias: Bytes,\n    #[allow(unused)]\n    table: Bytes,\n    alias: Bytes,\n    name: Bytes,\n    pub(crate) char_set: u16,\n    pub(crate) max_size: u32,\n    pub(crate) r#type: ColumnType,\n    pub(crate) flags: ColumnFlags,\n    #[allow(unused)]\n    decimals: u8,\n}\n\nimpl ColumnDefinition {\n    // NOTE: strings in-protocol are transmitted according to the client character set\n    //       as this is UTF-8, all these strings should be UTF-8\n\n    pub(crate) fn name(&self) -> Result<&str, Error> {\n        from_utf8(&self.name).map_err(Error::protocol)\n    }\n\n    pub(crate) fn alias(&self) -> Result<&str, Error> {\n        from_utf8(&self.alias).map_err(Error::protocol)\n    }\n}\n\nimpl Decode<'_, Capabilities> for ColumnDefinition {\n    fn decode_with(mut buf: Bytes, _: Capabilities) -> Result<Self, Error> {\n        let catalog = buf.get_bytes_lenenc();\n        let schema = buf.get_bytes_lenenc();\n        let table_alias = buf.get_bytes_lenenc();\n        let table = buf.get_bytes_lenenc();\n        let alias = buf.get_bytes_lenenc();\n        let name = buf.get_bytes_lenenc();\n        let _next_len = buf.get_uint_lenenc(); // always 0x0c\n        let char_set = buf.get_u16_le();\n        let max_size = buf.get_u32_le();\n        let type_id = buf.get_u8();\n        let flags = buf.get_u16_le();\n        let decimals = buf.get_u8();\n\n        Ok(Self {\n            catalog,\n            schema,\n            table_alias,\n            table,\n            alias,\n            name,\n            char_set,\n            max_size,\n            r#type: ColumnType::try_from_u16(type_id)?,\n            flags: ColumnFlags::from_bits_truncate(flags),\n            decimals,\n        })\n    }\n}\n\nimpl ColumnType {\n    pub(crate) fn name(\n        self,\n        char_set: u16,\n        flags: ColumnFlags,\n        max_size: Option<u32>,\n    ) -> &'static str {\n        let is_binary = char_set == 63;\n        let is_unsigned = flags.contains(ColumnFlags::UNSIGNED);\n        let is_enum = flags.contains(ColumnFlags::ENUM);\n\n        match self {\n            ColumnType::Tiny if max_size == Some(1) => \"BOOLEAN\",\n            ColumnType::Tiny if is_unsigned => \"TINYINT UNSIGNED\",\n            ColumnType::Short if is_unsigned => \"SMALLINT UNSIGNED\",\n            ColumnType::Long if is_unsigned => \"INT UNSIGNED\",\n            ColumnType::Int24 if is_unsigned => \"MEDIUMINT UNSIGNED\",\n            ColumnType::LongLong if is_unsigned => \"BIGINT UNSIGNED\",\n            ColumnType::Tiny => \"TINYINT\",\n            ColumnType::Short => \"SMALLINT\",\n            ColumnType::Long => \"INT\",\n            ColumnType::Int24 => \"MEDIUMINT\",\n            ColumnType::LongLong => \"BIGINT\",\n            ColumnType::Float => \"FLOAT\",\n            ColumnType::Double => \"DOUBLE\",\n            ColumnType::Null => \"NULL\",\n            ColumnType::Timestamp => \"TIMESTAMP\",\n            ColumnType::Date => \"DATE\",\n            ColumnType::Time => \"TIME\",\n            ColumnType::Datetime => \"DATETIME\",\n            ColumnType::Year => \"YEAR\",\n            ColumnType::Bit => \"BIT\",\n            ColumnType::Enum => \"ENUM\",\n            ColumnType::Set => \"SET\",\n            ColumnType::Decimal | ColumnType::NewDecimal => \"DECIMAL\",\n            ColumnType::Geometry => \"GEOMETRY\",\n            ColumnType::Json => \"JSON\",\n\n            ColumnType::String if is_binary => \"BINARY\",\n            ColumnType::String if is_enum => \"ENUM\",\n            ColumnType::VarChar | ColumnType::VarString if is_binary => \"VARBINARY\",\n\n            ColumnType::String => \"CHAR\",\n            ColumnType::VarChar | ColumnType::VarString => \"VARCHAR\",\n\n            ColumnType::TinyBlob if is_binary => \"TINYBLOB\",\n            ColumnType::TinyBlob => \"TINYTEXT\",\n\n            ColumnType::Blob if is_binary => \"BLOB\",\n            ColumnType::Blob => \"TEXT\",\n\n            ColumnType::MediumBlob if is_binary => \"MEDIUMBLOB\",\n            ColumnType::MediumBlob => \"MEDIUMTEXT\",\n\n            ColumnType::LongBlob if is_binary => \"LONGBLOB\",\n            ColumnType::LongBlob => \"LONGTEXT\",\n        }\n    }\n\n    pub(crate) fn try_from_u16(id: u8) -> Result<Self, Error> {\n        Ok(match id {\n            0x00 => ColumnType::Decimal,\n            0x01 => ColumnType::Tiny,\n            0x02 => ColumnType::Short,\n            0x03 => ColumnType::Long,\n            0x04 => ColumnType::Float,\n            0x05 => ColumnType::Double,\n            0x06 => ColumnType::Null,\n            0x07 => ColumnType::Timestamp,\n            0x08 => ColumnType::LongLong,\n            0x09 => ColumnType::Int24,\n            0x0a => ColumnType::Date,\n            0x0b => ColumnType::Time,\n            0x0c => ColumnType::Datetime,\n            0x0d => ColumnType::Year,\n            // [internal] 0x0e => ColumnType::NewDate,\n            0x0f => ColumnType::VarChar,\n            0x10 => ColumnType::Bit,\n            // [internal] 0x11 => ColumnType::Timestamp2,\n            // [internal] 0x12 => ColumnType::Datetime2,\n            // [internal] 0x13 => ColumnType::Time2,\n            0xf5 => ColumnType::Json,\n            0xf6 => ColumnType::NewDecimal,\n            0xf7 => ColumnType::Enum,\n            0xf8 => ColumnType::Set,\n            0xf9 => ColumnType::TinyBlob,\n            0xfa => ColumnType::MediumBlob,\n            0xfb => ColumnType::LongBlob,\n            0xfc => ColumnType::Blob,\n            0xfd => ColumnType::VarString,\n            0xfe => ColumnType::String,\n            0xff => ColumnType::Geometry,\n\n            _ => {\n                return Err(err_protocol!(\"unknown column type 0x{:02x}\", id));\n            }\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/text/mod.rs",
    "content": "mod column;\nmod ping;\nmod query;\nmod quit;\n\npub use column::{ColumnDefinition, ColumnFlags, ColumnType};\npub use ping::Ping;\npub use query::Query;\npub use quit::Quit;\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/text/ping.rs",
    "content": "use crate::io::Encode;\nuse crate::mysql::protocol::Capabilities;\n\n// https://dev.mysql.com/doc/internals/en/com-ping.html\n\n#[derive(Debug)]\npub struct Ping;\n\nimpl Encode<'_, Capabilities> for Ping {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: Capabilities) {\n        buf.push(0x0e); // COM_PING\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/text/query.rs",
    "content": "use bytes::{Buf, Bytes};\n\nuse crate::error::Error;\nuse crate::io::{BufExt, Decode, Encode};\nuse crate::mysql::protocol::Capabilities;\n\n// https://dev.mysql.com/doc/internals/en/com-query.html\n\n#[derive(Debug)]\npub struct Query(pub String);\n\nimpl Encode<'_, ()> for Query {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: ()) {\n        buf.push(0x03); // COM_QUERY\n        buf.extend(self.0.as_bytes())\n    }\n}\n\nimpl Encode<'_, Capabilities> for Query {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: Capabilities) {\n        buf.push(0x03); // COM_QUERY\n        buf.extend(self.0.as_bytes())\n    }\n}\n\nimpl Decode<'_> for Query {\n    fn decode_with(mut buf: Bytes, _: ()) -> Result<Self, Error> {\n        buf.advance(1);\n        let q = buf.get_str(buf.len())?;\n        Ok(Query(q))\n    }\n}\n"
  },
  {
    "path": "warpgate-database-protocols/src/mysql/protocol/text/quit.rs",
    "content": "use crate::io::Encode;\nuse crate::mysql::protocol::Capabilities;\n\n// https://dev.mysql.com/doc/internals/en/com-quit.html\n\n#[derive(Debug)]\npub struct Quit;\n\nimpl Encode<'_, Capabilities> for Quit {\n    fn encode_with(&self, buf: &mut Vec<u8>, _: Capabilities) {\n        buf.push(0x01); // COM_QUIT\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-db-entities\"\nversion = \"0.22.0\"\n\n[dependencies]\nbytes = { version = \"1.4\", default-features = false }\nchrono = { version = \"0.4\", default-features = false, features = [\"serde\"] }\npoem-openapi.workspace = true\nsqlx.workspace = true\nsea-orm = { workspace = true, features = [\n    \"macros\",\n    \"with-chrono\",\n    \"with-uuid\",\n    \"with-json\",\n], default-features = false }\nserde.workspace = true\nserde_json.workspace = true\nuuid.workspace = true\nwarpgate-common = { version = \"*\", path = \"../warpgate-common\", default-features = false }\nsecrecy = \"0.10\"\nwarpgate-tls = { version = \"*\", path = \"../warpgate-tls\", default-features = false }\nwarpgate-ldap = { version = \"*\", path = \"../warpgate-ldap\" }\n"
  },
  {
    "path": "warpgate-db-entities/src/AdminRole.rs",
    "content": "use poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n// Permissions on admin roles. All fields are simple bools. The naming here matches the\n// permission list defined in the admin UI and in the code elsewhere.\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"admin_roles\")]\n#[oai(rename = \"AdminRole\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub name: String,\n    #[sea_orm(column_type = \"Text\")]\n    pub description: String,\n\n    // permissions\n    pub targets_create: bool,\n    pub targets_edit: bool,\n    pub targets_delete: bool,\n\n    pub users_create: bool,\n    pub users_edit: bool,\n    pub users_delete: bool,\n\n    pub access_roles_create: bool,\n    pub access_roles_edit: bool,\n    pub access_roles_delete: bool,\n    pub access_roles_assign: bool,\n\n    pub sessions_view: bool,\n    pub sessions_terminate: bool,\n\n    pub recordings_view: bool,\n\n    pub tickets_create: bool,\n    pub tickets_delete: bool,\n\n    pub config_edit: bool,\n\n    pub admin_roles_manage: bool,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {\n    #[sea_orm(has_many = \"super::UserAdminRoleAssignment::Entity\")]\n    UserAdminRoleAssignment,\n}\n\nimpl Related<super::User::Entity> for Entity {\n    fn to() -> RelationDef {\n        super::UserAdminRoleAssignment::Relation::User.def()\n    }\n\n    fn via() -> Option<RelationDef> {\n        Some(\n            super::UserAdminRoleAssignment::Relation::AdminRole\n                .def()\n                .rev(),\n        )\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl From<Model> for warpgate_common::AdminRole {\n    fn from(model: Model) -> Self {\n        Self {\n            id: model.id,\n            name: model.name,\n            description: model.description,\n            targets_create: model.targets_create,\n            targets_edit: model.targets_edit,\n            targets_delete: model.targets_delete,\n            users_create: model.users_create,\n            users_edit: model.users_edit,\n            users_delete: model.users_delete,\n            access_roles_create: model.access_roles_create,\n            access_roles_edit: model.access_roles_edit,\n            access_roles_delete: model.access_roles_delete,\n            access_roles_assign: model.access_roles_assign,\n            sessions_view: model.sessions_view,\n            sessions_terminate: model.sessions_terminate,\n            recordings_view: model.recordings_view,\n            tickets_create: model.tickets_create,\n            tickets_delete: model.tickets_delete,\n            config_edit: model.config_edit,\n            admin_roles_manage: model.admin_roles_manage,\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/ApiToken.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sea_orm::entity::prelude::*;\nuse sea_orm::sea_query::ForeignKeyAction;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n#[sea_orm(table_name = \"api_tokens\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub user_id: Uuid,\n    pub label: String,\n    pub secret: String,\n    pub created: DateTime<Utc>,\n    pub expiry: DateTime<Utc>,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    User,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::User => Entity::belongs_to(super::User::Entity)\n                .from(Column::UserId)\n                .to(super::User::Column::Id)\n                .on_delete(ForeignKeyAction::Cascade)\n                .into(),\n        }\n    }\n}\n\nimpl Related<super::User::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::User.def()\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "warpgate-db-entities/src/CertificateCredential.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sea_orm::entity::prelude::*;\nuse sea_orm::sea_query::ForeignKeyAction;\nuse sea_orm::Set;\nuse serde::Serialize;\nuse uuid::Uuid;\nuse warpgate_common::{UserAuthCredential, UserCertificateCredential};\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n#[sea_orm(table_name = \"credentials_certificate\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub user_id: Uuid,\n    pub label: String,\n    pub date_added: Option<DateTime<Utc>>,\n    pub last_used: Option<DateTime<Utc>>,\n    #[sea_orm(column_type = \"Text\")]\n    pub certificate_pem: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    User,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::User => Entity::belongs_to(super::User::Entity)\n                .from(Column::UserId)\n                .to(super::User::Column::Id)\n                .on_delete(ForeignKeyAction::Cascade)\n                .into(),\n        }\n    }\n}\n\nimpl Related<super::User::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::User.def()\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl From<Model> for UserCertificateCredential {\n    fn from(credential: Model) -> Self {\n        UserCertificateCredential {\n            certificate_pem: credential.certificate_pem.into(),\n        }\n    }\n}\n\nimpl From<Model> for UserAuthCredential {\n    fn from(model: Model) -> Self {\n        Self::Certificate(model.into())\n    }\n}\n\nimpl From<UserCertificateCredential> for ActiveModel {\n    fn from(credential: UserCertificateCredential) -> Self {\n        Self {\n            certificate_pem: Set(credential.certificate_pem.expose_secret().clone()),\n            ..Default::default()\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/CertificateRevocation.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n#[sea_orm(table_name = \"certificate_revocations\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub serial_number_base64: String,\n    pub date_added: DateTime<Utc>,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "warpgate-db-entities/src/KnownHost.rs",
    "content": "use poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Object, Serialize)]\n#[sea_orm(table_name = \"known_hosts\")]\n#[oai(rename = \"SSHKnownHost\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub host: String,\n    pub port: i32,\n    pub key_type: String,\n    pub key_base64: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl Model {\n    pub fn key_openssh(&self) -> String {\n        format!(\"{} {}\", self.key_type, self.key_base64)\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/LdapServer.rs",
    "content": "use poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\nuse warpgate_tls::TlsMode;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"ldap_servers\")]\n#[oai(rename = \"LdapServer\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    #[sea_orm(unique)]\n    pub name: String,\n    pub host: String,\n    pub port: i32,\n    pub bind_dn: String,\n    pub bind_password: String,\n    pub user_filter: String,\n    pub base_dns: serde_json::Value,\n    pub tls_mode: String,\n    pub tls_verify: bool,\n    pub enabled: bool,\n    pub auto_link_sso_users: bool,\n    #[sea_orm(column_type = \"Text\")]\n    pub description: String,\n    pub username_attribute: String,\n    #[sea_orm(column_type = \"Text\")]\n    pub ssh_key_attribute: String,\n    pub uuid_attribute: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl TryFrom<&Model> for warpgate_ldap::LdapConfig {\n    type Error = serde_json::Error;\n\n    fn try_from(server: &Model) -> Result<Self, Self::Error> {\n        let base_dns: Vec<String> = serde_json::from_value(server.base_dns.clone())?;\n\n        Ok(Self {\n            host: server.host.clone(),\n            port: server.port as u16,\n            bind_dn: server.bind_dn.clone(),\n            bind_password: server.bind_password.clone(),\n            tls_mode: TlsMode::from(server.tls_mode.as_str()),\n            tls_verify: server.tls_verify,\n            base_dns,\n            user_filter: server.user_filter.clone(),\n            username_attribute: server\n                .username_attribute\n                .as_str()\n                .try_into()\n                .unwrap_or(warpgate_ldap::LdapUsernameAttribute::Cn),\n            ssh_key_attribute: server.ssh_key_attribute.clone(),\n            uuid_attribute: if server.uuid_attribute.is_empty() {\n                None\n            } else {\n                Some(server.uuid_attribute.clone())\n            },\n        })\n    }\n}\n\nimpl TryFrom<Model> for warpgate_ldap::LdapConfig {\n    type Error = serde_json::Error;\n\n    fn try_from(server: Model) -> Result<Self, Self::Error> {\n        Self::try_from(&server)\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/LogEntry.rs",
    "content": "use chrono::{DateTime, Utc};\nuse poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse sea_orm::query::JsonValue;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"log\")]\n#[oai(rename = \"LogEntry\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub text: String,\n    pub values: JsonValue,\n    pub timestamp: DateTime<Utc>,\n    pub session_id: Uuid,\n    pub username: Option<String>,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "warpgate-db-entities/src/OtpCredential.rs",
    "content": "use sea_orm::entity::prelude::*;\nuse sea_orm::sea_query::ForeignKeyAction;\nuse sea_orm::Set;\nuse serde::Serialize;\nuse uuid::Uuid;\nuse warpgate_common::{UserAuthCredential, UserTotpCredential};\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n#[sea_orm(table_name = \"credentials_otp\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub user_id: Uuid,\n    pub secret_key: Vec<u8>,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    User,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::User => Entity::belongs_to(super::User::Entity)\n                .from(Column::UserId)\n                .to(super::User::Column::Id)\n                .on_delete(ForeignKeyAction::Cascade)\n                .into(),\n        }\n    }\n}\n\nimpl Related<super::User::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::User.def()\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl From<Model> for UserTotpCredential {\n    fn from(credential: Model) -> Self {\n        UserTotpCredential {\n            key: credential.secret_key.into(),\n        }\n    }\n}\n\nimpl From<Model> for UserAuthCredential {\n    fn from(model: Model) -> Self {\n        Self::Totp(model.into())\n    }\n}\n\nimpl From<UserTotpCredential> for ActiveModel {\n    fn from(credential: UserTotpCredential) -> Self {\n        Self {\n            secret_key: Set(credential.key.expose_secret().clone()),\n            ..Default::default()\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/Parameters.rs",
    "content": "use sea_orm::entity::prelude::*;\nuse sea_orm::Set;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n#[sea_orm(table_name = \"parameters\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub allow_own_credential_management: bool,\n    pub rate_limit_bytes_per_second: Option<i64>,\n    #[sea_orm(column_type = \"Text\")]\n    pub ca_certificate_pem: String,\n    #[sea_orm(column_type = \"Text\")]\n    pub ca_private_key_pem: String,\n    pub ssh_client_auth_publickey: bool,\n    pub ssh_client_auth_password: bool,\n    pub ssh_client_auth_keyboard_interactive: bool,\n    pub minimize_password_login: bool,\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {}\n\nimpl Entity {\n    pub async fn get(db: &DatabaseConnection) -> Result<Model, DbErr> {\n        match Self::find().one(db).await? {\n            Some(model) => Ok(model),\n            None => {\n                ActiveModel {\n                    id: Set(Uuid::new_v4()),\n                    allow_own_credential_management: Set(true),\n                    rate_limit_bytes_per_second: Set(None),\n                    ca_certificate_pem: Set(\"\".into()),\n                    ca_private_key_pem: Set(\"\".into()),\n                    ssh_client_auth_publickey: Set(true),\n                    ssh_client_auth_password: Set(true),\n                    ssh_client_auth_keyboard_interactive: Set(true),\n                    minimize_password_login: Set(false),\n                }\n                .insert(db)\n                .await\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/PasswordCredential.rs",
    "content": "use sea_orm::entity::prelude::*;\nuse sea_orm::sea_query::ForeignKeyAction;\nuse sea_orm::Set;\nuse serde::Serialize;\nuse uuid::Uuid;\nuse warpgate_common::{UserAuthCredential, UserPasswordCredential};\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n#[sea_orm(table_name = \"credentials_password\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub user_id: Uuid,\n    pub argon_hash: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    User,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::User => Entity::belongs_to(super::User::Entity)\n                .from(Column::UserId)\n                .to(super::User::Column::Id)\n                .on_delete(ForeignKeyAction::Cascade)\n                .into(),\n        }\n    }\n}\n\nimpl Related<super::User::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::User.def()\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\npub struct StrictModel {\n    pub id: Uuid,\n    pub credential: UserPasswordCredential,\n}\n\nimpl From<Model> for StrictModel {\n    fn from(model: Model) -> Self {\n        Self {\n            id: model.id,\n            credential: model.clone().into(),\n        }\n    }\n}\n\nimpl From<Model> for UserPasswordCredential {\n    fn from(credential: Model) -> Self {\n        UserPasswordCredential {\n            hash: credential.argon_hash.to_owned().into(),\n        }\n    }\n}\n\nimpl From<Model> for UserAuthCredential {\n    fn from(model: Model) -> Self {\n        Self::Password(model.into())\n    }\n}\n\nimpl From<UserPasswordCredential> for ActiveModel {\n    fn from(credential: UserPasswordCredential) -> Self {\n        Self {\n            argon_hash: Set(credential.hash.expose_secret().into()),\n            ..Default::default()\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/PublicKeyCredential.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sea_orm::entity::prelude::*;\nuse sea_orm::sea_query::ForeignKeyAction;\nuse sea_orm::Set;\nuse serde::Serialize;\nuse uuid::Uuid;\nuse warpgate_common::{UserAuthCredential, UserPublicKeyCredential};\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n#[sea_orm(table_name = \"credentials_public_key\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub user_id: Uuid,\n    pub label: String,\n    pub date_added: Option<DateTime<Utc>>,\n    pub last_used: Option<DateTime<Utc>>,\n    #[sea_orm(column_type = \"Text\")]\n    pub openssh_public_key: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    User,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::User => Entity::belongs_to(super::User::Entity)\n                .from(Column::UserId)\n                .to(super::User::Column::Id)\n                .on_delete(ForeignKeyAction::Cascade)\n                .into(),\n        }\n    }\n}\n\nimpl Related<super::User::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::User.def()\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl From<Model> for UserPublicKeyCredential {\n    fn from(credential: Model) -> Self {\n        UserPublicKeyCredential {\n            key: credential.openssh_public_key.into(),\n        }\n    }\n}\n\nimpl From<Model> for UserAuthCredential {\n    fn from(model: Model) -> Self {\n        Self::PublicKey(model.into())\n    }\n}\n\nimpl From<UserPublicKeyCredential> for ActiveModel {\n    fn from(credential: UserPublicKeyCredential) -> Self {\n        Self {\n            openssh_public_key: Set(credential.key.expose_secret().clone()),\n            ..Default::default()\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/Recording.rs",
    "content": "use chrono::{DateTime, Utc};\nuse poem_openapi::{Enum, Object};\nuse sea_orm::entity::prelude::*;\nuse sea_orm::sea_query::ForeignKeyAction;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Debug, Clone, PartialEq, Eq, EnumIter, Enum, DeriveActiveEnum, Serialize)]\n#[sea_orm(rs_type = \"String\", db_type = \"String(StringLen::N(16))\")]\npub enum RecordingKind {\n    #[sea_orm(string_value = \"terminal\")]\n    Terminal,\n    #[sea_orm(string_value = \"traffic\")]\n    Traffic,\n    #[sea_orm(string_value = \"kubernetes\")]\n    Kubernetes,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"recordings\")]\n#[oai(rename = \"Recording\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub name: String,\n    pub started: DateTime<Utc>,\n    pub ended: Option<DateTime<Utc>>,\n    pub session_id: Uuid,\n    pub kind: RecordingKind,\n    #[sea_orm(column_type = \"Text\")]\n    pub metadata: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    Session,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::Session => Entity::belongs_to(super::Session::Entity)\n                .from(Column::SessionId)\n                .to(super::Session::Column::Id)\n                .on_delete(ForeignKeyAction::Cascade)\n                .into(),\n        }\n    }\n}\n\nimpl Related<super::Session::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::Session.def()\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "warpgate-db-entities/src/Role.rs",
    "content": "use poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\nuse warpgate_common::Role;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"roles\")]\n#[oai(rename = \"Role\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub name: String,\n    #[sea_orm(column_type = \"Text\")]\n    pub description: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {}\n\nimpl Related<super::Target::Entity> for Entity {\n    fn to() -> RelationDef {\n        super::TargetRoleAssignment::Relation::Target.def()\n    }\n\n    fn via() -> Option<RelationDef> {\n        Some(super::TargetRoleAssignment::Relation::Role.def().rev())\n    }\n}\n\nimpl Related<super::User::Entity> for Entity {\n    fn to() -> RelationDef {\n        super::UserRoleAssignment::Relation::User.def()\n    }\n\n    fn via() -> Option<RelationDef> {\n        Some(super::UserRoleAssignment::Relation::Role.def().rev())\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl From<Model> for Role {\n    fn from(model: Model) -> Self {\n        Self {\n            id: model.id,\n            name: model.name,\n            description: model.description,\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/Session.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sea_orm::entity::prelude::*;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n#[sea_orm(table_name = \"sessions\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub target_snapshot: Option<String>,\n    pub username: Option<String>,\n    pub remote_address: String,\n    pub started: DateTime<Utc>,\n    pub ended: Option<DateTime<Utc>>,\n    pub ticket_id: Option<Uuid>,\n    pub protocol: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    Recordings,\n    Ticket,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::Recordings => Entity::has_many(super::Recording::Entity)\n                .from(Column::Id)\n                .to(super::Recording::Column::SessionId)\n                .into(),\n            Self::Ticket => Entity::belongs_to(super::Ticket::Entity)\n                .from(Column::TicketId)\n                .to(super::Ticket::Column::Id)\n                .on_delete(ForeignKeyAction::SetNull)\n                .into(),\n        }\n    }\n}\n\nimpl Related<super::Ticket::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::Ticket.def()\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "warpgate-db-entities/src/SsoCredential.rs",
    "content": "use sea_orm::entity::prelude::*;\nuse sea_orm::sea_query::ForeignKeyAction;\nuse sea_orm::Set;\nuse serde::Serialize;\nuse uuid::Uuid;\nuse warpgate_common::{UserAuthCredential, UserSsoCredential};\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n#[sea_orm(table_name = \"credentials_sso\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub user_id: Uuid,\n    pub provider: Option<String>,\n    pub email: String,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    User,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::User => Entity::belongs_to(super::User::Entity)\n                .from(Column::UserId)\n                .to(super::User::Column::Id)\n                .on_delete(ForeignKeyAction::Cascade)\n                .into(),\n        }\n    }\n}\n\nimpl Related<super::User::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::User.def()\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl From<Model> for UserSsoCredential {\n    fn from(credential: Model) -> Self {\n        UserSsoCredential {\n            provider: credential.provider,\n            email: credential.email,\n        }\n    }\n}\n\nimpl From<Model> for UserAuthCredential {\n    fn from(model: Model) -> Self {\n        Self::Sso(model.into())\n    }\n}\n\nimpl From<UserSsoCredential> for ActiveModel {\n    fn from(credential: UserSsoCredential) -> Self {\n        Self {\n            provider: Set(credential.provider),\n            email: Set(credential.email),\n            ..Default::default()\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/Target.rs",
    "content": "use poem_openapi::{Enum, Object};\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\nuse warpgate_common::{Target, TargetOptions};\n\n#[derive(Debug, PartialEq, Eq, Serialize, Clone, Enum, EnumIter, DeriveActiveEnum)]\n#[sea_orm(rs_type = \"String\", db_type = \"String(StringLen::N(16))\")]\npub enum TargetKind {\n    #[sea_orm(string_value = \"http\")]\n    Http,\n    #[sea_orm(string_value = \"kubernetes\")]\n    Kubernetes,\n    #[sea_orm(string_value = \"mysql\")]\n    MySql,\n    #[sea_orm(string_value = \"ssh\")]\n    Ssh,\n    #[sea_orm(string_value = \"postgres\")]\n    Postgres,\n}\n\nimpl From<&TargetOptions> for TargetKind {\n    fn from(options: &TargetOptions) -> Self {\n        match options {\n            TargetOptions::Http(_) => Self::Http,\n            TargetOptions::Kubernetes(_) => Self::Kubernetes,\n            TargetOptions::MySql(_) => Self::MySql,\n            TargetOptions::Postgres(_) => Self::Postgres,\n            TargetOptions::Ssh(_) => Self::Ssh,\n        }\n    }\n}\n\n#[derive(Debug, PartialEq, Eq, Serialize, Clone, Enum, EnumIter, DeriveActiveEnum)]\n#[sea_orm(rs_type = \"String\", db_type = \"String(StringLen::N(16))\")]\npub enum SshAuthKind {\n    #[sea_orm(string_value = \"password\")]\n    Password,\n    #[sea_orm(string_value = \"publickey\")]\n    PublicKey,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"targets\")]\n#[oai(rename = \"Target\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub name: String,\n    #[sea_orm(column_type = \"Text\")]\n    pub description: String,\n    pub kind: TargetKind,\n    pub options: serde_json::Value,\n    pub rate_limit_bytes_per_second: Option<i64>,\n    pub group_id: Option<Uuid>,\n}\n\nimpl Related<super::Role::Entity> for Entity {\n    fn to() -> RelationDef {\n        super::TargetRoleAssignment::Relation::Role.def()\n    }\n\n    fn via() -> Option<RelationDef> {\n        Some(super::TargetRoleAssignment::Relation::Target.def().rev())\n    }\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {\n    #[sea_orm(\n        belongs_to = \"super::TargetGroup::Entity\",\n        from = \"Column::GroupId\",\n        to = \"super::TargetGroup::Column::Id\"\n    )]\n    TargetGroup,\n}\n\nimpl Related<super::TargetGroup::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::TargetGroup.def()\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl TryFrom<Model> for Target {\n    type Error = serde_json::Error;\n\n    fn try_from(model: Model) -> Result<Self, Self::Error> {\n        let options: TargetOptions = serde_json::from_value(model.options)?;\n        Ok(Self {\n            id: model.id,\n            name: model.name,\n            description: model.description,\n            allow_roles: vec![],\n            options,\n            rate_limit_bytes_per_second: model.rate_limit_bytes_per_second.map(|v| v as u32),\n            group_id: model.group_id,\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/TargetGroup.rs",
    "content": "use poem_openapi::{Enum, Object};\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Debug, PartialEq, Eq, Serialize, Clone, Enum, EnumIter, DeriveActiveEnum)]\n#[sea_orm(rs_type = \"String\", db_type = \"String(StringLen::N(16))\")]\npub enum BootstrapThemeColor {\n    #[sea_orm(string_value = \"primary\")]\n    Primary,\n    #[sea_orm(string_value = \"secondary\")]\n    Secondary,\n    #[sea_orm(string_value = \"success\")]\n    Success,\n    #[sea_orm(string_value = \"danger\")]\n    Danger,\n    #[sea_orm(string_value = \"warning\")]\n    Warning,\n    #[sea_orm(string_value = \"info\")]\n    Info,\n    #[sea_orm(string_value = \"light\")]\n    Light,\n    #[sea_orm(string_value = \"dark\")]\n    Dark,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"target_groups\")]\n#[oai(rename = \"TargetGroup\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub name: String,\n    #[sea_orm(column_type = \"Text\")]\n    pub description: String,\n    pub color: Option<BootstrapThemeColor>, // Bootstrap theme color for UI display\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {\n    #[sea_orm(has_many = \"super::Target::Entity\")]\n    Target,\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "warpgate-db-entities/src/TargetRoleAssignment.rs",
    "content": "use poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"target_roles\")]\n#[oai(rename = \"TargetRoleAssignment\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = true)]\n    pub id: i32,\n    pub target_id: Uuid,\n    pub role_id: Uuid,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    Target,\n    Role,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::Target => Entity::belongs_to(super::Target::Entity)\n                .from(Column::TargetId)\n                .to(super::Target::Column::Id)\n                .into(),\n            Self::Role => Entity::belongs_to(super::Role::Entity)\n                .from(Column::RoleId)\n                .to(super::Role::Column::Id)\n                .into(),\n        }\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "warpgate-db-entities/src/Ticket.rs",
    "content": "use chrono::{DateTime, Utc};\nuse poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"tickets\")]\n#[oai(rename = \"Ticket\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    #[oai(skip)]\n    pub secret: String,\n    pub username: String,\n    #[sea_orm(column_type = \"Text\")]\n    pub description: String,\n    pub target: String,\n    pub uses_left: Option<i16>,\n    pub expiry: Option<DateTime<Utc>>,\n    pub created: DateTime<Utc>,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\npub enum Relation {\n    #[sea_orm(has_many = \"super::Session::Entity\")]\n    Sessions,\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "warpgate-db-entities/src/User.rs",
    "content": "use poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse sea_orm::Set;\nuse serde::Serialize;\nuse uuid::Uuid;\nuse warpgate_common::{User, UserDetails, WarpgateError};\n\nuse crate::{\n    CertificateCredential, OtpCredential, PasswordCredential, PublicKeyCredential, Role,\n    SsoCredential,\n};\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"users\")]\n#[oai(rename = \"User\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = false)]\n    pub id: Uuid,\n    pub username: String,\n    pub credential_policy: serde_json::Value,\n    #[sea_orm(column_type = \"Text\")]\n    pub description: String,\n    pub rate_limit_bytes_per_second: Option<i64>,\n    pub ldap_server_id: Option<Uuid>,\n    #[sea_orm(column_type = \"Text\", nullable)]\n    pub ldap_object_uuid: Option<Uuid>,\n}\n\nimpl Related<super::Role::Entity> for Entity {\n    fn to() -> RelationDef {\n        super::UserRoleAssignment::Relation::Role.def()\n    }\n\n    fn via() -> Option<RelationDef> {\n        Some(super::UserRoleAssignment::Relation::User.def().rev())\n    }\n}\n\nimpl Related<super::AdminRole::Entity> for Entity {\n    fn to() -> RelationDef {\n        super::UserAdminRoleAssignment::Relation::AdminRole.def()\n    }\n\n    fn via() -> Option<RelationDef> {\n        Some(super::UserAdminRoleAssignment::Relation::User.def().rev())\n    }\n}\n\nimpl Related<super::OtpCredential::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::OtpCredentials.def()\n    }\n}\n\nimpl Related<super::PasswordCredential::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::PasswordCredentials.def()\n    }\n}\n\nimpl Related<super::PublicKeyCredential::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::PublicKeyCredentials.def()\n    }\n}\n\nimpl Related<super::CertificateCredential::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::CertificateCredentials.def()\n    }\n}\n\nimpl Related<super::SsoCredential::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::SsoCredentials.def()\n    }\n}\n\nimpl Related<super::ApiToken::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::ApiTokens.def()\n    }\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\n#[allow(clippy::enum_variant_names)]\npub enum Relation {\n    OtpCredentials,\n    PasswordCredentials,\n    PublicKeyCredentials,\n    CertificateCredentials,\n    SsoCredentials,\n    ApiTokens,\n    AdminRoles,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::OtpCredentials => Entity::has_many(super::OtpCredential::Entity)\n                .from(Column::Id)\n                .to(super::OtpCredential::Column::UserId)\n                .into(),\n            Self::PasswordCredentials => Entity::has_many(super::PasswordCredential::Entity)\n                .from(Column::Id)\n                .to(super::PasswordCredential::Column::UserId)\n                .into(),\n            Self::PublicKeyCredentials => Entity::has_many(super::PublicKeyCredential::Entity)\n                .from(Column::Id)\n                .to(super::PublicKeyCredential::Column::UserId)\n                .into(),\n            Self::CertificateCredentials => Entity::has_many(super::CertificateCredential::Entity)\n                .from(Column::Id)\n                .to(super::CertificateCredential::Column::UserId)\n                .into(),\n            Self::SsoCredentials => Entity::has_many(super::SsoCredential::Entity)\n                .from(Column::Id)\n                .to(super::SsoCredential::Column::UserId)\n                .into(),\n            Self::ApiTokens => Entity::has_many(super::ApiToken::Entity)\n                .from(Column::Id)\n                .to(super::ApiToken::Column::UserId)\n                .into(),\n            Self::AdminRoles => Entity::has_many(super::UserAdminRoleAssignment::Entity)\n                .from(Column::Id)\n                .to(super::UserAdminRoleAssignment::Column::UserId)\n                .into(),\n        }\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl TryFrom<Model> for User {\n    type Error = WarpgateError;\n\n    fn try_from(model: Model) -> Result<Self, WarpgateError> {\n        Ok(User {\n            id: model.id,\n            username: model.username,\n            credential_policy: serde_json::from_value(model.credential_policy)?,\n            description: model.description,\n            rate_limit_bytes_per_second: model.rate_limit_bytes_per_second,\n            ldap_server_id: model.ldap_server_id,\n        })\n    }\n}\n\nimpl Model {\n    pub async fn load_details(self, db: &DatabaseConnection) -> Result<UserDetails, WarpgateError> {\n        let roles: Vec<String> = self\n            .find_related(Role::Entity)\n            .all(db)\n            .await?\n            .into_iter()\n            .map(Into::<warpgate_common::Role>::into)\n            .map(|x| x.name)\n            .collect();\n\n        let mut credentials = vec![];\n        credentials.extend(\n            self.find_related(OtpCredential::Entity)\n                .all(db)\n                .await?\n                .into_iter()\n                .map(|x| x.into()),\n        );\n        credentials.extend(\n            self.find_related(PasswordCredential::Entity)\n                .all(db)\n                .await?\n                .into_iter()\n                .map(|x| x.into()),\n        );\n        credentials.extend(\n            self.find_related(SsoCredential::Entity)\n                .all(db)\n                .await?\n                .into_iter()\n                .map(|x| x.into()),\n        );\n        credentials.extend(\n            self.find_related(PublicKeyCredential::Entity)\n                .all(db)\n                .await?\n                .into_iter()\n                .map(|x| x.into()),\n        );\n        credentials.extend(\n            self.find_related(CertificateCredential::Entity)\n                .all(db)\n                .await?\n                .into_iter()\n                .map(|x| x.into()),\n        );\n\n        Ok(warpgate_common::UserDetails {\n            inner: self.try_into()?,\n            roles,\n            credentials,\n        })\n    }\n}\n\nimpl TryFrom<User> for ActiveModel {\n    type Error = WarpgateError;\n\n    fn try_from(user: User) -> Result<Self, Self::Error> {\n        Ok(Self {\n            id: Set(user.id),\n            username: Set(user.username),\n            credential_policy: Set(serde_json::to_value(&user.credential_policy)?),\n            description: Set(user.description),\n            rate_limit_bytes_per_second: Set(user.rate_limit_bytes_per_second),\n            ldap_server_id: Set(None),\n            ldap_object_uuid: Set(None),\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/UserAdminRoleAssignment.rs",
    "content": "use poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"user_admin_roles\")]\n#[oai(rename = \"UserAdminRoleAssignment\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = true)]\n    pub id: i32,\n    pub user_id: Uuid,\n    pub admin_role_id: Uuid,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    User,\n    AdminRole,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::User => Entity::belongs_to(super::User::Entity)\n                .from(Column::UserId)\n                .to(super::User::Column::Id)\n                .into(),\n            Self::AdminRole => Entity::belongs_to(super::AdminRole::Entity)\n                .from(Column::AdminRoleId)\n                .to(super::AdminRole::Column::Id)\n                .into(),\n        }\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n\nimpl Related<super::AdminRole::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::AdminRole.def()\n    }\n}\n\nimpl Related<super::User::Entity> for Entity {\n    fn to() -> RelationDef {\n        Relation::User.def()\n    }\n}\n"
  },
  {
    "path": "warpgate-db-entities/src/UserRoleAssignment.rs",
    "content": "use poem_openapi::Object;\nuse sea_orm::entity::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]\n#[sea_orm(table_name = \"user_roles\")]\n#[oai(rename = \"UserRoleAssignment\")]\npub struct Model {\n    #[sea_orm(primary_key, auto_increment = true)]\n    pub id: i32,\n    pub user_id: Uuid,\n    pub role_id: Uuid,\n}\n\n#[derive(Copy, Clone, Debug, EnumIter)]\npub enum Relation {\n    User,\n    Role,\n}\n\nimpl RelationTrait for Relation {\n    fn def(&self) -> RelationDef {\n        match self {\n            Self::User => Entity::belongs_to(super::User::Entity)\n                .from(Column::UserId)\n                .to(super::User::Column::Id)\n                .into(),\n            Self::Role => Entity::belongs_to(super::Role::Entity)\n                .from(Column::RoleId)\n                .to(super::Role::Column::Id)\n                .into(),\n        }\n    }\n}\n\nimpl ActiveModelBehavior for ActiveModel {}\n"
  },
  {
    "path": "warpgate-db-entities/src/lib.rs",
    "content": "#![allow(non_snake_case)]\n\npub mod AdminRole;\npub mod ApiToken;\npub mod CertificateCredential;\npub mod CertificateRevocation;\npub mod KnownHost;\npub mod LdapServer;\npub mod LogEntry;\npub mod OtpCredential;\npub mod Parameters;\npub mod PasswordCredential;\npub mod PublicKeyCredential;\npub mod Recording;\npub mod Role;\npub mod Session;\npub mod SsoCredential;\npub mod Target;\npub mod TargetGroup;\npub mod TargetRoleAssignment;\npub mod Ticket;\npub mod User;\npub mod UserAdminRoleAssignment;\npub mod UserRoleAssignment;\n"
  },
  {
    "path": "warpgate-db-migrations/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-db-migrations\"\npublish = false\nversion = \"0.22.0\"\n\n[lib]\n\n[dependencies]\ntokio.workspace = true\nchrono = { version = \"0.4\", default-features = false, features = [\"serde\"] }\ndata-encoding.workspace = true\nregex.workspace = true\nsea-orm = { workspace = true, features = [\n    \"with-chrono\",\n    \"with-uuid\",\n    \"with-json\",\n], default-features = false }\nsea-orm-migration.workspace = true\nrussh.workspace = true\ntracing.workspace = true\nuuid.workspace = true\nserde_json.workspace = true\nserde.workspace = true\nwarpgate-ca = { version = \"*\", path = \"../warpgate-ca\", default-features = false }\n\n[features]\npostgres = [\"sea-orm/sqlx-postgres\"]\nmysql = [\"sea-orm/sqlx-mysql\"]\nsqlite = [\"sea-orm/sqlx-sqlite\"]\n"
  },
  {
    "path": "warpgate-db-migrations/README.md",
    "content": "# Running Migrator CLI\n\n- Apply all pending migrations\n    ```sh\n    cargo run\n    ```\n    ```sh\n    cargo run -- up\n    ```\n- Apply first 10 pending migrations\n    ```sh\n    cargo run -- up -n 10\n    ```\n- Rollback last applied migrations\n    ```sh\n    cargo run -- down\n    ```\n- Rollback last 10 applied migrations\n    ```sh\n    cargo run -- down -n 10\n    ```\n- Drop all tables from the database, then reapply all migrations\n    ```sh\n    cargo run -- fresh\n    ```\n- Rollback all applied migrations, then reapply all migrations\n    ```sh\n    cargo run -- refresh\n    ```\n- Rollback all applied migrations\n    ```sh\n    cargo run -- reset\n    ```\n- Check the status of all migrations\n    ```sh\n    cargo run -- status\n    ```\n"
  },
  {
    "path": "warpgate-db-migrations/src/lib.rs",
    "content": "use sea_orm::DatabaseConnection;\nuse sea_orm_migration::prelude::*;\nuse sea_orm_migration::MigrationTrait;\n\nmod m00001_create_ticket;\nmod m00002_create_session;\nmod m00003_create_recording;\nmod m00004_create_known_host;\nmod m00005_create_log_entry;\nmod m00006_add_session_protocol;\nmod m00007_targets_and_roles;\nmod m00008_users;\nmod m00009_credential_models;\nmod m00010_parameters;\nmod m00011_rsa_key_algos;\nmod m00012_add_openssh_public_key_label;\nmod m00013_add_openssh_public_key_dates;\nmod m00014_api_tokens;\nmod m00015_fix_public_key_dates;\nmod m00016_fix_public_key_length;\nmod m00017_descriptions;\nmod m00018_ticket_description;\nmod m00019_rate_limits;\nmod m00020_target_groups;\nmod m00021_ldap_server;\nmod m00022_user_ldap_link;\nmod m00023_ldap_username_attribute;\nmod m00024_ssh_key_attribute;\nmod m00025_ldap_uuid_attribute;\nmod m00026_ssh_client_auth;\nmod m00027_ca;\nmod m00028_certificate_credentials;\nmod m00029_certificate_revocation;\nmod m00030_add_recording_metadata;\nmod m00031_minimize_password_login;\nmod m00032_admin_roles;\n\npub struct Migrator;\n\n#[async_trait::async_trait]\nimpl MigratorTrait for Migrator {\n    fn migrations() -> Vec<Box<dyn MigrationTrait>> {\n        vec![\n            Box::new(m00001_create_ticket::Migration),\n            Box::new(m00002_create_session::Migration),\n            Box::new(m00003_create_recording::Migration),\n            Box::new(m00004_create_known_host::Migration),\n            Box::new(m00005_create_log_entry::Migration),\n            Box::new(m00006_add_session_protocol::Migration),\n            Box::new(m00007_targets_and_roles::Migration),\n            Box::new(m00008_users::Migration),\n            Box::new(m00009_credential_models::Migration),\n            Box::new(m00010_parameters::Migration),\n            Box::new(m00011_rsa_key_algos::Migration),\n            Box::new(m00012_add_openssh_public_key_label::Migration),\n            Box::new(m00013_add_openssh_public_key_dates::Migration),\n            Box::new(m00014_api_tokens::Migration),\n            Box::new(m00015_fix_public_key_dates::Migration),\n            Box::new(m00016_fix_public_key_length::Migration),\n            Box::new(m00017_descriptions::Migration),\n            Box::new(m00018_ticket_description::Migration),\n            Box::new(m00019_rate_limits::Migration),\n            Box::new(m00020_target_groups::Migration),\n            Box::new(m00021_ldap_server::Migration),\n            Box::new(m00022_user_ldap_link::Migration),\n            Box::new(m00023_ldap_username_attribute::Migration),\n            Box::new(m00024_ssh_key_attribute::Migration),\n            Box::new(m00025_ldap_uuid_attribute::Migration),\n            Box::new(m00026_ssh_client_auth::Migration),\n            Box::new(m00027_ca::Migration),\n            Box::new(m00028_certificate_credentials::Migration),\n            Box::new(m00029_certificate_revocation::Migration),\n            Box::new(m00030_add_recording_metadata::Migration),\n            Box::new(m00031_minimize_password_login::Migration),\n            Box::new(m00032_admin_roles::Migration),\n        ]\n    }\n}\n\npub async fn migrate_database(connection: &DatabaseConnection) -> Result<(), DbErr> {\n    Migrator::up(connection, None).await\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00001_create_ticket.rs",
    "content": "use sea_orm::{DbBackend, Schema};\nuse sea_orm_migration::prelude::*;\n\npub mod ticket {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"tickets\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub secret: String,\n        pub username: String,\n        pub target: String,\n        pub uses_left: Option<i16>,\n        pub expiry: Option<DateTimeUtc>,\n        pub created: DateTimeUtc,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00001_create_ticket\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n\n        manager\n            .create_table(schema.create_table_from_entity(ticket::Entity))\n            .await?;\n\n        let connection = manager.get_connection();\n        if connection.get_database_backend() == DbBackend::MySql {\n            // https://github.com/warp-tech/warpgate/issues/857\n            connection\n                .execute_unprepared(\n                    \"ALTER TABLE `tickets` MODIFY COLUMN `expiry` TIMESTAMP NULL DEFAULT NULL\",\n                )\n                .await?;\n        }\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(Table::drop().table(ticket::Entity).to_owned())\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00002_create_session.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\npub mod session {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    use crate::m00001_create_ticket::ticket;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"sessions\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub target_snapshot: Option<String>,\n        pub username: Option<String>,\n        pub remote_address: String,\n        pub started: DateTimeUtc,\n        pub ended: Option<DateTimeUtc>,\n        pub ticket_id: Option<Uuid>,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        Ticket,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::Ticket => Entity::belongs_to(ticket::Entity)\n                    .from(Column::TicketId)\n                    .to(ticket::Column::Id)\n                    .on_delete(ForeignKeyAction::SetNull)\n                    .into(),\n            }\n        }\n    }\n\n    impl Related<ticket::Entity> for Entity {\n        fn to() -> RelationDef {\n            Relation::Ticket.def()\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00002_create_session\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(session::Entity))\n            .await\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(Table::drop().table(session::Entity).to_owned())\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00003_create_recording.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\npub mod recording {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    use crate::m00002_create_session::session;\n\n    #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]\n    #[sea_orm(rs_type = \"String\", db_type = \"String(StringLen::N(16))\")]\n    pub enum RecordingKind {\n        #[sea_orm(string_value = \"terminal\")]\n        Terminal,\n        #[sea_orm(string_value = \"traffic\")]\n        Traffic,\n        #[sea_orm(string_value = \"kubernetes\")]\n        Kubernetes,\n    }\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"recordings\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub name: String,\n        pub started: DateTimeUtc,\n        pub ended: Option<DateTimeUtc>,\n        pub session_id: Uuid,\n        pub kind: RecordingKind,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        Session,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::Session => Entity::belongs_to(session::Entity)\n                    .from(Column::SessionId)\n                    .to(session::Column::Id)\n                    .on_delete(ForeignKeyAction::Cascade)\n                    .into(),\n            }\n        }\n    }\n\n    impl Related<session::Entity> for Entity {\n        fn to() -> RelationDef {\n            Relation::Session.def()\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00003_create_recording\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(recording::Entity))\n            .await?;\n        manager\n            .create_index(\n                Index::create()\n                    .table(recording::Entity)\n                    .name(\"recording__unique__session_id__name\")\n                    .unique()\n                    .col(recording::Column::SessionId)\n                    .col(recording::Column::Name)\n                    .to_owned(),\n            )\n            .await\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(Table::drop().table(recording::Entity).to_owned())\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00004_create_known_host.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\npub mod known_host {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"known_hosts\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub host: String,\n        pub port: i32,\n        pub key_type: String,\n        pub key_base64: String,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00004_create_known_host\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(known_host::Entity))\n            .await\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(Table::drop().table(known_host::Entity).to_owned())\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00005_create_log_entry.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\npub mod log_entry {\n    use chrono::{DateTime, Utc};\n    use sea_orm::entity::prelude::*;\n    use sea_orm::query::JsonValue;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"log\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub text: String,\n        pub values: JsonValue,\n        pub timestamp: DateTime<Utc>,\n        pub session_id: Uuid,\n        pub username: Option<String>,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00005_create_log_entry\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(log_entry::Entity))\n            .await?;\n        manager\n            .create_index(\n                Index::create()\n                    .table(log_entry::Entity)\n                    .name(\"log_entry__timestamp_session_id\")\n                    .col(log_entry::Column::Timestamp)\n                    .col(log_entry::Column::SessionId)\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .create_index(\n                Index::create()\n                    .table(log_entry::Entity)\n                    .name(\"log_entry__session_id\")\n                    .col(log_entry::Column::SessionId)\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .create_index(\n                Index::create()\n                    .table(log_entry::Entity)\n                    .name(\"log_entry__username\")\n                    .col(log_entry::Column::Username)\n                    .to_owned(),\n            )\n            .await\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(Table::drop().table(log_entry::Entity).to_owned())\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00006_add_session_protocol.rs",
    "content": "use sea_orm_migration::prelude::*;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00006_add_session_protocol\"\n    }\n}\n\nuse crate::m00002_create_session::session;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(session::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"protocol\"))\n                            .string()\n                            .not_null()\n                            .default(\"SSH\"),\n                    )\n                    .to_owned(),\n            )\n            .await\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(session::Entity)\n                    .drop_column(Alias::new(\"protocol\"))\n                    .to_owned(),\n            )\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00007_targets_and_roles.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\npub(crate) mod role {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"roles\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub name: String,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub mod target {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Debug, PartialEq, Eq, Clone, EnumIter, DeriveActiveEnum)]\n    #[sea_orm(rs_type = \"String\", db_type = \"String(StringLen::N(16))\")]\n    pub enum TargetKind {\n        #[sea_orm(string_value = \"http\")]\n        Http,\n        #[sea_orm(string_value = \"mysql\")]\n        MySql,\n        #[sea_orm(string_value = \"ssh\")]\n        Ssh,\n        #[sea_orm(string_value = \"web_admin\")]\n        WebAdmin,\n    }\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"targets\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub name: String,\n        pub kind: TargetKind,\n        pub options: serde_json::Value,\n    }\n\n    impl Related<super::role::Entity> for Entity {\n        fn to() -> RelationDef {\n            super::target_role_assignment::Relation::Target.def()\n        }\n\n        fn via() -> Option<RelationDef> {\n            Some(super::target_role_assignment::Relation::Role.def().rev())\n        }\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub(crate) mod target_role_assignment {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"target_roles\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = true)]\n        pub id: i32,\n        pub target_id: Uuid,\n        pub role_id: Uuid,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        Target,\n        Role,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::Target => Entity::belongs_to(super::target::Entity)\n                    .from(Column::TargetId)\n                    .to(super::target::Column::Id)\n                    .into(),\n                Self::Role => Entity::belongs_to(super::role::Entity)\n                    .from(Column::RoleId)\n                    .to(super::role::Column::Id)\n                    .into(),\n            }\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00007_targets_and_roles\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(role::Entity))\n            .await?;\n        manager\n            .create_table(schema.create_table_from_entity(target::Entity))\n            .await?;\n        manager\n            .create_table(schema.create_table_from_entity(target_role_assignment::Entity))\n            .await?;\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(\n                Table::drop()\n                    .table(target_role_assignment::Entity)\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .drop_table(Table::drop().table(target::Entity).to_owned())\n            .await?;\n        manager\n            .drop_table(Table::drop().table(role::Entity).to_owned())\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00008_users.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\npub mod user {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"users\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub username: String,\n        pub credentials: serde_json::Value,\n        pub credential_policy: serde_json::Value,\n    }\n\n    impl Related<crate::m00007_targets_and_roles::role::Entity> for Entity {\n        fn to() -> RelationDef {\n            super::user_role_assignment::Relation::User.def()\n        }\n\n        fn via() -> Option<RelationDef> {\n            Some(super::user_role_assignment::Relation::Role.def().rev())\n        }\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub(crate) mod user_role_assignment {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"user_roles\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = true)]\n        pub id: i32,\n        pub user_id: Uuid,\n        pub role_id: Uuid,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        User,\n        Role,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::User => Entity::belongs_to(super::user::Entity)\n                    .from(Column::UserId)\n                    .to(super::user::Column::Id)\n                    .into(),\n                Self::Role => Entity::belongs_to(crate::m00007_targets_and_roles::role::Entity)\n                    .from(Column::RoleId)\n                    .to(crate::m00007_targets_and_roles::role::Column::Id)\n                    .into(),\n            }\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00008_users\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(user::Entity))\n            .await?;\n        manager\n            .create_table(schema.create_table_from_entity(user_role_assignment::Entity))\n            .await?;\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(Table::drop().table(user_role_assignment::Entity).to_owned())\n            .await?;\n        manager\n            .drop_table(Table::drop().table(user::Entity).to_owned())\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00009_credential_models.rs",
    "content": "use credential_enum::UserAuthCredential;\nuse sea_orm::{ActiveModelTrait, EntityTrait, Schema, Set};\nuse sea_orm_migration::prelude::*;\nuse tracing::error;\nuse uuid::Uuid;\n\nuse super::m00008_users::user as User;\n\npub mod otp_credential {\n    use sea_orm::entity::prelude::*;\n    use sea_orm::sea_query::ForeignKeyAction;\n    use uuid::Uuid;\n\n    use crate::m00008_users::user as User;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"credentials_otp\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub user_id: Uuid,\n        pub secret_key: Vec<u8>,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        User,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::User => Entity::belongs_to(User::Entity)\n                    .from(Column::UserId)\n                    .to(User::Column::Id)\n                    .on_delete(ForeignKeyAction::Cascade)\n                    .into(),\n            }\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub mod password_credential {\n    use sea_orm::entity::prelude::*;\n    use sea_orm::sea_query::ForeignKeyAction;\n    use uuid::Uuid;\n\n    use crate::m00008_users::user as User;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"credentials_password\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub user_id: Uuid,\n        pub argon_hash: String,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        User,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::User => Entity::belongs_to(User::Entity)\n                    .from(Column::UserId)\n                    .to(User::Column::Id)\n                    .on_delete(ForeignKeyAction::Cascade)\n                    .into(),\n            }\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub mod public_key_credential {\n    use sea_orm::entity::prelude::*;\n    use sea_orm::sea_query::ForeignKeyAction;\n    use uuid::Uuid;\n\n    use crate::m00008_users::user as User;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"credentials_public_key\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub user_id: Uuid,\n        #[sea_orm(column_type = \"Text\")]\n        pub openssh_public_key: String,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        User,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::User => Entity::belongs_to(User::Entity)\n                    .from(Column::UserId)\n                    .to(User::Column::Id)\n                    .on_delete(ForeignKeyAction::Cascade)\n                    .into(),\n            }\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\nmod sso_credential {\n    use sea_orm::entity::prelude::*;\n    use sea_orm::sea_query::ForeignKeyAction;\n    use uuid::Uuid;\n\n    use crate::m00008_users::user as User;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"credentials_sso\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub user_id: Uuid,\n        pub provider: Option<String>,\n        pub email: String,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        User,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::User => Entity::belongs_to(User::Entity)\n                    .from(Column::UserId)\n                    .to(User::Column::Id)\n                    .on_delete(ForeignKeyAction::Cascade)\n                    .into(),\n            }\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\nmod credential_enum {\n    use serde::{Deserialize, Serialize};\n\n    mod serde_base64_secret {\n        use serde::Serializer;\n\n        mod serde_base64 {\n            use data_encoding::BASE64;\n            use serde::{Deserialize, Serializer};\n\n            pub fn serialize<S: Serializer, B: AsRef<[u8]>>(\n                bytes: B,\n                serializer: S,\n            ) -> Result<S::Ok, S::Error> {\n                serializer.serialize_str(&BASE64.encode(bytes.as_ref()))\n            }\n\n            pub fn deserialize<'de, D: serde::Deserializer<'de>, B: From<Vec<u8>>>(\n                deserializer: D,\n            ) -> Result<B, D::Error> {\n                let s = String::deserialize(deserializer)?;\n                Ok(BASE64\n                    .decode(s.as_bytes())\n                    .map_err(serde::de::Error::custom)?\n                    .into())\n            }\n        }\n\n        pub fn serialize<S: Serializer>(\n            secret: &Vec<u8>,\n            serializer: S,\n        ) -> Result<S::Ok, S::Error> {\n            serde_base64::serialize(secret, serializer)\n        }\n\n        pub fn deserialize<'de, D: serde::Deserializer<'de>>(\n            deserializer: D,\n        ) -> Result<Vec<u8>, D::Error> {\n            let inner = serde_base64::deserialize(deserializer)?;\n            Ok(inner)\n        }\n    }\n\n    #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]\n    #[serde(tag = \"type\")]\n    pub enum UserAuthCredential {\n        #[serde(rename = \"password\")]\n        Password(UserPasswordCredential),\n        #[serde(rename = \"publickey\")]\n        PublicKey(UserPublicKeyCredential),\n        #[serde(rename = \"otp\")]\n        Totp(UserTotpCredential),\n        #[serde(rename = \"sso\")]\n        Sso(UserSsoCredential),\n    }\n\n    #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]\n    pub struct UserPasswordCredential {\n        pub hash: String,\n    }\n    #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]\n    pub struct UserPublicKeyCredential {\n        pub key: String,\n    }\n    #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]\n    pub struct UserTotpCredential {\n        #[serde(with = \"serde_base64_secret\")]\n        pub key: Vec<u8>,\n    }\n    #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]\n    pub struct UserSsoCredential {\n        pub provider: Option<String>,\n        pub email: String,\n    }\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00009_credential_models\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let db = manager.get_connection();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(otp_credential::Entity))\n            .await?;\n        manager\n            .create_table(schema.create_table_from_entity(password_credential::Entity))\n            .await?;\n        manager\n            .create_table(schema.create_table_from_entity(public_key_credential::Entity))\n            .await?;\n        manager\n            .create_table(schema.create_table_from_entity(sso_credential::Entity))\n            .await?;\n\n        let users = User::Entity::find().all(db).await?;\n        for user in users {\n            #[allow(clippy::unwrap_used)]\n            let Ok(credentials) =\n                serde_json::from_value::<Vec<UserAuthCredential>>(user.credentials.clone())\n            else {\n                error!(\n                    \"Failed to parse credentials for user {}, value was {:?}\",\n                    user.id, user.credentials\n                );\n                continue;\n            };\n            for credential in credentials {\n                match credential {\n                    UserAuthCredential::Password(password) => {\n                        let model = password_credential::ActiveModel {\n                            id: Set(Uuid::new_v4()),\n                            user_id: Set(user.id),\n                            argon_hash: Set(password.hash),\n                        };\n                        model.insert(db).await?;\n                    }\n                    UserAuthCredential::PublicKey(key) => {\n                        let model = public_key_credential::ActiveModel {\n                            id: Set(Uuid::new_v4()),\n                            user_id: Set(user.id),\n                            openssh_public_key: Set(key.key),\n                        };\n                        model.insert(db).await?;\n                    }\n                    UserAuthCredential::Sso(sso) => {\n                        let model = sso_credential::ActiveModel {\n                            id: Set(Uuid::new_v4()),\n                            user_id: Set(user.id),\n                            provider: Set(sso.provider),\n                            email: Set(sso.email),\n                        };\n                        model.insert(db).await?;\n                    }\n                    UserAuthCredential::Totp(totp) => {\n                        let model = otp_credential::ActiveModel {\n                            id: Set(Uuid::new_v4()),\n                            user_id: Set(user.id),\n                            secret_key: Set(totp.key),\n                        };\n                        model.insert(db).await?;\n                    }\n                }\n            }\n        }\n\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(User::Entity)\n                    .drop_column(User::Column::Credentials)\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n\n    #[allow(clippy::panic, reason = \"dev only\")]\n    async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {\n        panic!(\"This migration is irreversible\");\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00010_parameters.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\npub mod parameters {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"parameters\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub allow_own_credential_management: bool,\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00010_parameters\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(parameters::Entity))\n            .await?;\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(Table::drop().table(parameters::Entity).to_owned())\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00011_rsa_key_algos.rs",
    "content": "use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, Set};\nuse sea_orm_migration::prelude::*;\nuse tracing::error;\n\nuse crate::m00009_credential_models::public_key_credential as PKC;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00011_rsa_key_algos\"\n    }\n}\n\n/// Re-save all keys so that rsa-sha2-* gets replaced with ssh-rsa\n/// since ssh-keys never serializes key type as rsa-sha2-*\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let connection = manager.get_connection();\n        let creds = PKC::Entity::find().all(connection).await?;\n        for cred in creds.into_iter() {\n            let parsed = match russh::keys::PublicKey::from_openssh(&cred.openssh_public_key) {\n                Ok(parsed) => parsed,\n                Err(e) => {\n                    error!(\"Failed to parse public key '{cred:?}': {e}\");\n                    continue;\n                }\n            };\n            let serialized = parsed\n                .to_openssh()\n                .map_err(|e| DbErr::Custom(format!(\"Failed to serialize public key: {e}\")))?;\n            let am = PKC::ActiveModel {\n                openssh_public_key: Set(serialized),\n                ..cred.into_active_model()\n            };\n            am.update(connection).await?;\n        }\n        Ok(())\n    }\n\n    async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00012_add_openssh_public_key_label.rs",
    "content": "use sea_orm_migration::prelude::*;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00012_add_openssh_public_key_label\"\n    }\n}\n\nuse crate::m00009_credential_models::public_key_credential;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(public_key_credential::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"label\"))\n                            .string()\n                            .not_null()\n                            .default(\"Public Key\"),\n                    )\n                    .to_owned(),\n            )\n            .await\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(public_key_credential::Entity)\n                    .drop_column(Alias::new(\"label\"))\n                    .to_owned(),\n            )\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00013_add_openssh_public_key_dates.rs",
    "content": "use sea_orm_migration::prelude::*;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00013_add_openssh_public_key_dates\"\n    }\n}\n\nuse crate::m00009_credential_models::public_key_credential;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        // Add 'date_added' column\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(public_key_credential::Entity)\n                    .add_column(ColumnDef::new(Alias::new(\"date_added\")).date_time().null())\n                    .to_owned(),\n            )\n            .await?;\n\n        // Add 'last_used' column\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(public_key_credential::Entity)\n                    .add_column(ColumnDef::new(Alias::new(\"last_used\")).date_time().null())\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        // Drop 'last_used' column\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(public_key_credential::Entity)\n                    .drop_column(Alias::new(\"last_used\"))\n                    .to_owned(),\n            )\n            .await?;\n\n        // Drop 'date_added' column\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(public_key_credential::Entity)\n                    .drop_column(Alias::new(\"date_added\"))\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00014_api_tokens.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\nuse super::m00008_users::user as User;\n\npub mod api_tokens {\n    use chrono::{DateTime, Utc};\n    use sea_orm::entity::prelude::*;\n    use sea_orm::sea_query::ForeignKeyAction;\n    use serde::Serialize;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n    #[sea_orm(table_name = \"api_tokens\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub user_id: Uuid,\n        pub label: String,\n        pub secret: String,\n        pub created: DateTime<Utc>,\n        pub expiry: DateTime<Utc>,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        User,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::User => Entity::belongs_to(super::User::Entity)\n                    .from(Column::UserId)\n                    .to(super::User::Column::Id)\n                    .on_delete(ForeignKeyAction::Cascade)\n                    .into(),\n            }\n        }\n    }\n\n    impl Related<super::User::Entity> for Entity {\n        fn to() -> RelationDef {\n            Relation::User.def()\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00014_api_tokens\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(api_tokens::Entity))\n            .await?;\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(Table::drop().table(api_tokens::Entity).to_owned())\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00015_fix_public_key_dates.rs",
    "content": "use sea_orm::DbBackend;\nuse sea_orm_migration::prelude::*;\n\n/// timestamp_with_time_zone() was originally missing on these columns\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00015_fix_public_key_dates\"\n    }\n}\n\nuse crate::m00009_credential_models::public_key_credential;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let connection = manager.get_connection();\n        if connection.get_database_backend() != DbBackend::Sqlite {\n            manager\n                .alter_table(\n                    Table::alter()\n                        .table(public_key_credential::Entity)\n                        .modify_column(\n                            ColumnDef::new(Alias::new(\"date_added\"))\n                                .date_time()\n                                .timestamp_with_time_zone()\n                                .null(),\n                        )\n                        .to_owned(),\n                )\n                .await?;\n\n            manager\n                .alter_table(\n                    Table::alter()\n                        .table(public_key_credential::Entity)\n                        .modify_column(\n                            ColumnDef::new(Alias::new(\"last_used\"))\n                                .date_time()\n                                .timestamp_with_time_zone()\n                                .null(),\n                        )\n                        .to_owned(),\n                )\n                .await?;\n        }\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let connection = manager.get_connection();\n        if connection.get_database_backend() != DbBackend::Sqlite {\n            manager\n                .alter_table(\n                    Table::alter()\n                        .table(public_key_credential::Entity)\n                        .modify_column(ColumnDef::new(Alias::new(\"date_added\")).date_time().null())\n                        .to_owned(),\n                )\n                .await?;\n\n            manager\n                .alter_table(\n                    Table::alter()\n                        .table(public_key_credential::Entity)\n                        .modify_column(ColumnDef::new(Alias::new(\"last_used\")).date_time().null())\n                        .to_owned(),\n                )\n                .await?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00016_fix_public_key_length.rs",
    "content": "use sea_orm::DbBackend;\nuse sea_orm_migration::prelude::*;\n\n/// The original column type was `String` which defaults to VARCHAR(255) on MySQL\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00016_fix_public_key_length\"\n    }\n}\n\nuse crate::m00009_credential_models::public_key_credential;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let connection = manager.get_connection();\n        if connection.get_database_backend() != DbBackend::Sqlite {\n            manager\n                .alter_table(\n                    Table::alter()\n                        .table(public_key_credential::Entity)\n                        .modify_column(ColumnDef::new(Alias::new(\"openssh_public_key\")).text())\n                        .to_owned(),\n                )\n                .await?;\n        }\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let connection = manager.get_connection();\n        if connection.get_database_backend() != DbBackend::Sqlite {\n            manager\n                .alter_table(\n                    Table::alter()\n                        .table(public_key_credential::Entity)\n                        .modify_column(ColumnDef::new(Alias::new(\"openssh_public_key\")).string())\n                        .to_owned(),\n                )\n                .await?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00017_descriptions.rs",
    "content": "use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};\nuse sea_orm_migration::prelude::*;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00017_descriptions\"\n    }\n}\n\nuse crate::m00007_targets_and_roles::target;\nuse crate::m00008_users::user;\n\npub mod role {\n    use sea_orm::entity::prelude::*;\n    use serde::Serialize;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n    #[sea_orm(table_name = \"roles\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub name: String,\n        #[sea_orm(column_type = \"Text\")]\n        pub description: String,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(user::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"description\"))\n                            .text()\n                            .not_null()\n                            .default(\"\"),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(role::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"description\"))\n                            .text()\n                            .not_null()\n                            .default(\"\"),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(target::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"description\"))\n                            .text()\n                            .not_null()\n                            .default(\"\"),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n\n        // set description for builtin admin role\n        if let Some(admin_role) = role::Entity::find()\n            .filter(role::Column::Name.eq(\"warpgate:admin\"))\n            .one(manager.get_connection())\n            .await?\n        {\n            role::ActiveModel {\n                id: Set(admin_role.id),\n                description: Set(\"Built-in admin role\".into()),\n                name: Set(admin_role.name),\n            }\n            .update(manager.get_connection())\n            .await?;\n        }\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(target::Entity)\n                    .drop_column(Alias::new(\"description\"))\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(user::Entity)\n                    .drop_column(Alias::new(\"description\"))\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(role::Entity)\n                    .drop_column(Alias::new(\"description\"))\n                    .to_owned(),\n            )\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00018_ticket_description.rs",
    "content": "use sea_orm_migration::prelude::*;\n\nuse crate::m00001_create_ticket::ticket;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00018_ticket_description\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(ticket::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"description\"))\n                            .text()\n                            .not_null()\n                            .default(\"\"),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(ticket::Entity)\n                    .drop_column(Alias::new(\"description\"))\n                    .to_owned(),\n            )\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00019_rate_limits.rs",
    "content": "use sea_orm_migration::prelude::*;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00019_rate_limits\"\n    }\n}\n\nuse crate::m00007_targets_and_roles::target;\nuse crate::m00008_users::user;\nuse crate::m00010_parameters::parameters;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(user::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"rate_limit_bytes_per_second\"))\n                            .big_integer()\n                            .null(),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"rate_limit_bytes_per_second\"))\n                            .big_integer()\n                            .null(),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(target::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"rate_limit_bytes_per_second\"))\n                            .big_integer()\n                            .null(),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(target::Entity)\n                    .drop_column(Alias::new(\"rate_limit_bytes_per_second\"))\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(user::Entity)\n                    .drop_column(Alias::new(\"rate_limit_bytes_per_second\"))\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .drop_column(Alias::new(\"rate_limit_bytes_per_second\"))\n                    .to_owned(),\n            )\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00020_target_groups.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\nuse crate::m00007_targets_and_roles::target;\n\npub mod target_group {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Debug, PartialEq, Eq, Clone, EnumIter, DeriveActiveEnum)]\n    #[sea_orm(rs_type = \"String\", db_type = \"String(StringLen::N(16))\")]\n    pub enum BootstrapThemeColor {\n        #[sea_orm(string_value = \"primary\")]\n        Primary,\n        #[sea_orm(string_value = \"secondary\")]\n        Secondary,\n        #[sea_orm(string_value = \"success\")]\n        Success,\n        #[sea_orm(string_value = \"danger\")]\n        Danger,\n        #[sea_orm(string_value = \"warning\")]\n        Warning,\n        #[sea_orm(string_value = \"info\")]\n        Info,\n        #[sea_orm(string_value = \"light\")]\n        Light,\n        #[sea_orm(string_value = \"dark\")]\n        Dark,\n    }\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"target_groups\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub name: String,\n        #[sea_orm(column_type = \"Text\")]\n        pub description: String,\n        pub color: Option<BootstrapThemeColor>,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00020_target_groups\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        // Create target_groups table\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(target_group::Entity))\n            .await?;\n\n        // Add group_id column to targets table\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(target::Entity)\n                    .add_column(ColumnDef::new(Alias::new(\"group_id\")).uuid().null())\n                    .to_owned(),\n            )\n            .await?;\n\n        // Note: SQLite doesn't support adding foreign key constraints to existing tables\n        // The foreign key relationship will be enforced at the application level\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        // Remove group_id column from targets table\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(target::Entity)\n                    .drop_column(Alias::new(\"group_id\"))\n                    .to_owned(),\n            )\n            .await?;\n\n        // Drop target_groups table\n        manager\n            .drop_table(Table::drop().table(target_group::Entity).to_owned())\n            .await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00021_ldap_server.rs",
    "content": "use sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\npub mod ldap_server {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"ldap_servers\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        #[sea_orm(unique)]\n        pub name: String,\n        pub host: String,\n        pub port: i32,\n        pub bind_dn: String,\n        pub bind_password: String,\n        pub user_filter: String,\n        pub base_dns: serde_json::Value,\n        pub tls_mode: String,\n        pub tls_verify: bool,\n        pub enabled: bool,\n        pub auto_link_sso_users: bool,\n        #[sea_orm(column_type = \"Text\")]\n        pub description: String,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00020_ldap_server\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(ldap_server::Entity))\n            .await?;\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(Table::drop().table(ldap_server::Entity).to_owned())\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00022_user_ldap_link.rs",
    "content": "use sea_orm_migration::prelude::*;\n\npub struct Migration;\n\npub mod user {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"users\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub username: String,\n        pub credential_policy: serde_json::Value,\n        #[sea_orm(column_type = \"Text\")]\n        pub description: String,\n        pub rate_limit_bytes_per_second: Option<i64>,\n        pub ldap_server_id: Option<Uuid>,\n        #[sea_orm(column_type = \"Text\", nullable)]\n        pub ldap_object_uuid: Option<String>,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00021_user_ldap_link\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(user::Entity)\n                    .add_column(ColumnDef::new(user::Column::LdapServerId).uuid().null())\n                    .to_owned(),\n            )\n            .await?;\n\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(user::Entity)\n                    .add_column(ColumnDef::new(user::Column::LdapObjectUuid).text().null())\n                    .to_owned(),\n            )\n            .await?;\n\n        // Add index on ldap_server_id for foreign key-like lookups\n        manager\n            .create_index(\n                Index::create()\n                    .name(\"idx_users_ldap_server_id\")\n                    .table(user::Entity)\n                    .col(user::Column::LdapServerId)\n                    .to_owned(),\n            )\n            .await?;\n\n        // Add unique index on (ldap_server_id, ldap_object_uuid) to ensure no duplicates\n        manager\n            .create_index(\n                Index::create()\n                    .name(\"idx_users_ldap_unique\")\n                    .table(user::Entity)\n                    .col(user::Column::LdapServerId)\n                    .col(user::Column::LdapObjectUuid)\n                    .unique()\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_index(\n                Index::drop()\n                    .name(\"idx_users_ldap_unique\")\n                    .table(user::Entity)\n                    .to_owned(),\n            )\n            .await?;\n\n        manager\n            .drop_index(\n                Index::drop()\n                    .name(\"idx_users_ldap_server_id\")\n                    .table(user::Entity)\n                    .to_owned(),\n            )\n            .await?;\n\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(user::Entity)\n                    .drop_column(user::Column::LdapObjectUuid)\n                    .to_owned(),\n            )\n            .await?;\n\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(user::Entity)\n                    .drop_column(user::Column::LdapServerId)\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00023_ldap_username_attribute.rs",
    "content": "use sea_orm_migration::prelude::*;\n\nuse super::m00021_ldap_server::ldap_server;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00023_ldap_username_attribute\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(ldap_server::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"username_attribute\"))\n                            .string()\n                            .not_null()\n                            .default(\"uid\"),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(ldap_server::Entity)\n                    .drop_column(Alias::new(\"username_attribute\"))\n                    .to_owned(),\n            )\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00024_ssh_key_attribute.rs",
    "content": "use sea_orm_migration::prelude::*;\n\nuse super::m00021_ldap_server::ldap_server;\n\n#[derive(DeriveMigrationName)]\npub struct Migration;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(ldap_server::Entity)\n                    .add_column_if_not_exists(\n                        ColumnDef::new(Alias::new(\"ssh_key_attribute\"))\n                            .string()\n                            .not_null()\n                            .default(\"sshPublicKey\"),\n                    )\n                    .to_owned(),\n            )\n            .await\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(ldap_server::Entity)\n                    .drop_column(Alias::new(\"ssh_key_attribute\"))\n                    .to_owned(),\n            )\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00025_ldap_uuid_attribute.rs",
    "content": "use sea_orm_migration::prelude::*;\n\nuse super::m00021_ldap_server::ldap_server;\n\n#[derive(DeriveMigrationName)]\npub struct Migration;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(ldap_server::Entity)\n                    .add_column_if_not_exists(\n                        ColumnDef::new(Alias::new(\"uuid_attribute\"))\n                            .string()\n                            .not_null()\n                            .default(\"\"),\n                    )\n                    .to_owned(),\n            )\n            .await\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(ldap_server::Entity)\n                    .drop_column(Alias::new(\"uuid_attribute\"))\n                    .to_owned(),\n            )\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00026_ssh_client_auth.rs",
    "content": "use sea_orm_migration::prelude::*;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00025_ssh_client_auth\"\n    }\n}\n\nuse crate::m00010_parameters::parameters;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"ssh_client_auth_publickey\"))\n                            .boolean()\n                            .not_null()\n                            .default(true),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"ssh_client_auth_password\"))\n                            .boolean()\n                            .not_null()\n                            .default(true),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"ssh_client_auth_keyboard_interactive\"))\n                            .boolean()\n                            .not_null()\n                            .default(true),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .drop_column(Alias::new(\"ssh_client_auth_keyboard_interactive\"))\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .drop_column(Alias::new(\"ssh_client_auth_password\"))\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .drop_column(Alias::new(\"ssh_client_auth_publickey\"))\n                    .to_owned(),\n            )\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00027_ca.rs",
    "content": "use sea_orm::ActiveValue::Set;\nuse sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel};\nuse sea_orm_migration::prelude::*;\nuse tracing::info;\nuse uuid::Uuid;\n\npub mod parameters {\n    use sea_orm::entity::prelude::*;\n    use uuid::Uuid;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"parameters\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub allow_own_credential_management: bool,\n        pub rate_limit_bytes_per_second: Option<i64>,\n\n        #[sea_orm(column_type = \"Text\")]\n        pub ca_certificate_pem: String,\n        #[sea_orm(column_type = \"Text\")]\n        pub ca_private_key_pem: String,\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n}\n\n#[derive(DeriveMigrationName)]\npub struct Migration;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"ca_certificate_pem\"))\n                            .text()\n                            .not_null()\n                            .default(\"\"),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"ca_private_key_pem\"))\n                            .text()\n                            .not_null()\n                            .default(\"\"),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n\n        info!(\"Generating root CA certificate\");\n        let (cert_pem, pk_pem) = warpgate_ca::generate_root_certificate_rcgen()\n            .map_err(|e| DbErr::Custom(format!(\"Failed to generate CA certificate: {}\", e)))?;\n\n        let db = manager.get_connection();\n\n        let parameters = match parameters::Entity::find().one(db).await? {\n            Some(model) => Ok(model),\n            None => {\n                parameters::ActiveModel {\n                    id: Set(Uuid::new_v4()),\n                    allow_own_credential_management: Set(true),\n                    rate_limit_bytes_per_second: Set(None),\n                    ca_certificate_pem: Set(\"\".into()),\n                    ca_private_key_pem: Set(\"\".into()),\n                }\n                .insert(db)\n                .await\n            }\n        }?;\n\n        let mut model = parameters.into_active_model();\n        model.ca_certificate_pem = Set(cert_pem);\n        model.ca_private_key_pem = Set(pk_pem);\n        model.update(db).await?;\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .drop_column(Alias::new(\"ca_certificate_pem\"))\n                    .to_owned(),\n            )\n            .await?;\n\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .drop_column(Alias::new(\"ca_private_key_pem\"))\n                    .to_owned(),\n            )\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00028_certificate_credentials.rs",
    "content": "use chrono::{DateTime, Utc};\nuse sea_orm::entity::prelude::*;\nuse sea_orm::sea_query::ForeignKeyAction;\nuse sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\nuse serde::Serialize;\nuse uuid::Uuid;\n\nuse super::m00008_users::user as User;\n\npub mod certificate_credential {\n    use super::*;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n    #[sea_orm(table_name = \"credentials_certificate\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub user_id: Uuid,\n        pub label: String,\n        pub date_added: Option<DateTime<Utc>>,\n        pub last_used: Option<DateTime<Utc>>,\n        #[sea_orm(column_type = \"Text\")]\n        pub certificate_pem: String,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        User,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::User => Entity::belongs_to(super::User::Entity)\n                    .from(Column::UserId)\n                    .to(super::User::Column::Id)\n                    .on_delete(ForeignKeyAction::Cascade)\n                    .into(),\n            }\n        }\n    }\n\n    impl Related<super::User::Entity> for Entity {\n        fn to() -> RelationDef {\n            Relation::User.def()\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\n#[derive(DeriveMigrationName)]\npub struct Migration;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(certificate_credential::Entity))\n            .await?;\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_table(\n                Table::drop()\n                    .table(certificate_credential::Entity)\n                    .to_owned(),\n            )\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00029_certificate_revocation.rs",
    "content": "use sea_orm::entity::prelude::*;\nuse sea_orm::Schema;\nuse sea_orm_migration::prelude::*;\n\npub mod certificate_revocation {\n    use chrono::{DateTime, Utc};\n    use sea_orm::entity::prelude::*;\n    use serde::Serialize;\n    use uuid::Uuid;\n\n    use super::*;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]\n    #[sea_orm(table_name = \"certificate_revocations\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub serial_number_base64: String,\n        pub date_added: DateTime<Utc>,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\n#[derive(DeriveMigrationName)]\npub struct Migration;\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n        manager\n            .create_table(schema.create_table_from_entity(certificate_revocation::Entity))\n            .await?;\n        manager\n            .create_index(\n                Index::create()\n                    .name(\"idx_certificate_revocations_serial\")\n                    .table(certificate_revocation::Entity)\n                    .col(certificate_revocation::Column::SerialNumberBase64)\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .drop_index(\n                Index::drop()\n                    .name(\"idx_certificate_revocations_serial\")\n                    .table(certificate_revocation::Entity)\n                    .to_owned(),\n            )\n            .await?;\n        manager\n            .drop_table(\n                Table::drop()\n                    .table(certificate_revocation::Entity)\n                    .to_owned(),\n            )\n            .await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00030_add_recording_metadata.rs",
    "content": "use sea_orm_migration::prelude::*;\n\npub struct Migration;\nuse super::m00003_create_recording::recording;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00030_add_recording_metadata\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(recording::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"metadata\"))\n                            .text()\n                            .default(\"null\"),\n                    )\n                    .to_owned(),\n            )\n            .await\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(recording::Entity)\n                    .drop_column(Alias::new(\"metadata\"))\n                    .to_owned(),\n            )\n            .await\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00031_minimize_password_login.rs",
    "content": "use sea_orm_migration::prelude::*;\n\nuse crate::m00010_parameters::parameters;\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00031_minimize_password_login\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .add_column(\n                        ColumnDef::new(Alias::new(\"minimize_password_login\"))\n                            .boolean()\n                            .not_null()\n                            .default(false),\n                    )\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n\n    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        manager\n            .alter_table(\n                Table::alter()\n                    .table(parameters::Entity)\n                    .drop_column(Alias::new(\"minimize_password_login\"))\n                    .to_owned(),\n            )\n            .await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/m00032_admin_roles.rs",
    "content": "use sea_orm::prelude::*;\nuse sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Schema, Set};\nuse sea_orm_migration::prelude::*;\nuse uuid::Uuid;\n\nuse crate::m00007_targets_and_roles::{target, target_role_assignment};\nuse crate::m00008_users::user_role_assignment;\nuse crate::m00022_user_ldap_link::user;\n\npub(crate) mod admin_role {\n    use super::*;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"admin_roles\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = false)]\n        pub id: Uuid,\n        pub name: String,\n        #[sea_orm(column_type = \"Text\")]\n        pub description: String,\n        pub targets_create: bool,\n        pub targets_edit: bool,\n        pub targets_delete: bool,\n        pub users_create: bool,\n        pub users_edit: bool,\n        pub users_delete: bool,\n        pub access_roles_create: bool,\n        pub access_roles_edit: bool,\n        pub access_roles_delete: bool,\n        pub access_roles_assign: bool,\n        pub sessions_view: bool,\n        pub sessions_terminate: bool,\n        pub recordings_view: bool,\n        pub tickets_create: bool,\n        pub tickets_delete: bool,\n        pub config_edit: bool,\n        pub admin_roles_manage: bool,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]\n    pub enum Relation {}\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub(crate) mod user_admin_role {\n    use super::*;\n\n    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]\n    #[sea_orm(table_name = \"user_admin_roles\")]\n    pub struct Model {\n        #[sea_orm(primary_key, auto_increment = true)]\n        pub id: i32,\n        pub user_id: Uuid,\n        pub admin_role_id: Uuid,\n    }\n\n    #[derive(Copy, Clone, Debug, EnumIter)]\n    pub enum Relation {\n        User,\n        AdminRole,\n    }\n\n    impl RelationTrait for Relation {\n        fn def(&self) -> RelationDef {\n            match self {\n                Self::User => Entity::belongs_to(user::Entity)\n                    .from(Column::UserId)\n                    .to(user::Column::Id)\n                    .into(),\n                Self::AdminRole => Entity::belongs_to(super::admin_role::Entity)\n                    .from(Column::AdminRoleId)\n                    .to(super::admin_role::Column::Id)\n                    .into(),\n            }\n        }\n    }\n\n    impl ActiveModelBehavior for ActiveModel {}\n}\n\npub struct Migration;\n\nimpl MigrationName for Migration {\n    fn name(&self) -> &str {\n        \"m00032_admin_roles\"\n    }\n}\n\n#[async_trait::async_trait]\nimpl MigrationTrait for Migration {\n    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {\n        let builder = manager.get_database_backend();\n        let schema = Schema::new(builder);\n\n        manager\n            .create_table(schema.create_table_from_entity(admin_role::Entity))\n            .await?;\n        manager\n            .create_table(schema.create_table_from_entity(user_admin_role::Entity))\n            .await?;\n\n        // migrate existing warpgate:admin users to a new admin role with full permissions\n        let conn = manager.get_connection();\n\n        // find all users authorized for the web admin target without complex joins\n        let admin_targets = target::Entity::find()\n            .filter(target::Column::Kind.eq(target::TargetKind::WebAdmin))\n            .all(conn)\n            .await?;\n        let mut admin_users: Vec<user::Model> = Vec::new();\n\n        if !admin_targets.is_empty() {\n            let web_target_ids: Vec<Uuid> = admin_targets.into_iter().map(|t| t.id).collect();\n\n            let web_role_assignments = target_role_assignment::Entity::find()\n                .filter(target_role_assignment::Column::TargetId.is_in(web_target_ids.clone()))\n                .all(conn)\n                .await?;\n\n            let web_role_ids: Vec<Uuid> = web_role_assignments\n                .into_iter()\n                .map(|a| a.role_id)\n                .collect();\n\n            if !web_role_ids.is_empty() {\n                // now get all users that have one of those roles\n                let user_assignments = user_role_assignment::Entity::find()\n                    .filter(user_role_assignment::Column::RoleId.is_in(web_role_ids.clone()))\n                    .all(conn)\n                    .await?;\n\n                if !user_assignments.is_empty() {\n                    let user_ids: Vec<Uuid> =\n                        user_assignments.into_iter().map(|a| a.user_id).collect();\n                    admin_users = user::Entity::find()\n                        .filter(user::Column::Id.is_in(user_ids.clone()))\n                        .all(conn)\n                        .await?;\n                }\n            }\n        }\n\n        let builtin_admin_role_id = Uuid::new_v4();\n        let values = admin_role::ActiveModel {\n            id: Set(builtin_admin_role_id),\n            name: Set(\"warpgate:admin\".to_string()),\n            description: Set(\"Built-in admin role\".into()),\n            targets_create: Set(true),\n            targets_edit: Set(true),\n            targets_delete: Set(true),\n            users_create: Set(true),\n            users_edit: Set(true),\n            users_delete: Set(true),\n            access_roles_create: Set(true),\n            access_roles_edit: Set(true),\n            access_roles_delete: Set(true),\n            access_roles_assign: Set(true),\n            sessions_view: Set(true),\n            sessions_terminate: Set(true),\n            recordings_view: Set(true),\n            tickets_create: Set(true),\n            tickets_delete: Set(true),\n            config_edit: Set(true),\n            admin_roles_manage: Set(true),\n        };\n        values.insert(conn).await?;\n\n        for user in admin_users {\n            let assign = user_admin_role::ActiveModel {\n                user_id: Set(user.id),\n                admin_role_id: Set(builtin_admin_role_id),\n                ..Default::default()\n            };\n            assign.insert(conn).await?;\n        }\n\n        // drop the old web-admin target and its assignments as it's no longer used\n        {\n            if let Some(web_target) = target::Entity::find()\n                .filter(target::Column::Kind.eq(target::TargetKind::WebAdmin))\n                .one(conn)\n                .await?\n            {\n                target_role_assignment::Entity::delete_many()\n                    .filter(target_role_assignment::Column::TargetId.eq(web_target.id))\n                    .exec(conn)\n                    .await?;\n\n                target::Entity::delete_by_id(web_target.id)\n                    .exec(conn)\n                    .await?;\n            }\n        }\n\n        Ok(())\n    }\n\n    async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {\n        panic!(\"This migration cannot be reversed\");\n    }\n}\n"
  },
  {
    "path": "warpgate-db-migrations/src/main.rs",
    "content": "use sea_orm_migration::prelude::*;\nuse warpgate_db_migrations::Migrator;\n\n#[tokio::main]\nasync fn main() {\n    cli::run_cli(Migrator).await;\n}\n"
  },
  {
    "path": "warpgate-ldap/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-ldap\"\nversion = \"0.22.0\"\n\n[dependencies]\nanyhow.workspace = true\nldap3.workspace = true\nserde.workspace = true\nserde_json.workspace = true\nthiserror.workspace = true\npoem-openapi.workspace = true\ntokio.workspace = true\ntracing.workspace = true\nuuid.workspace = true\nwarpgate-tls = { version = \"*\", path = \"../warpgate-tls\", default-features = false }\n"
  },
  {
    "path": "warpgate-ldap/src/connection.rs",
    "content": "use std::sync::Arc;\nuse std::time::Duration;\n\nuse ldap3::{Ldap, LdapConnAsync, LdapConnSettings, Scope, SearchEntry};\nuse tracing::{debug, info, warn};\nuse warpgate_tls::{configure_tls_connector, TlsMode};\n\nuse crate::error::{LdapError, Result};\nuse crate::types::LdapConfig;\n\npub async fn connect(config: &LdapConfig) -> Result<Ldap> {\n    let url = build_ldap_url(config);\n    debug!(\"Connecting to LDAP server: {}\", url);\n\n    let connector = Arc::new(configure_tls_connector(!config.tls_verify, false, None).await?);\n\n    // Configure connection settings based on TLS mode\n    let settings = LdapConnSettings::new()\n        .set_starttls(\n            matches!(config.tls_mode, TlsMode::Preferred | TlsMode::Required) && config.port != 636,\n        )\n        .set_conn_timeout(Duration::from_secs(10))\n        .set_no_tls_verify(!config.tls_verify)\n        .set_config(connector);\n\n    let (conn, mut ldap) = LdapConnAsync::with_settings(settings, &url)\n        .await\n        .map_err(|e| LdapError::ConnectionFailed(e.to_string()))?;\n\n    tokio::spawn(async move {\n        if let Err(e) = conn.drive().await {\n            warn!(\"LDAP connection driver error: {}\", e);\n        }\n    });\n\n    // Bind with credentials\n    ldap.simple_bind(&config.bind_dn, &config.bind_password)\n        .await\n        .map_err(|e| LdapError::AuthenticationFailed(e.to_string()))?\n        .success()\n        .map_err(|e| LdapError::AuthenticationFailed(e.to_string()))?;\n\n    Ok(ldap)\n}\n\npub async fn test_connection(config: &LdapConfig) -> Result<bool> {\n    match connect(config).await {\n        Ok(mut ldap) => {\n            // Try to unbind cleanly\n            let _ = ldap.unbind().await;\n            Ok(true)\n        }\n        Err(e) => {\n            debug!(\"Test connection failed: {}\", e);\n            Err(e)\n        }\n    }\n}\n\npub async fn discover_base_dns(config: &LdapConfig) -> Result<Vec<String>> {\n    let mut ldap = connect(config).await?;\n\n    debug!(\"Querying rootDSE for naming contexts\");\n\n    // Query rootDSE for namingContexts\n    let (rs, _res) = ldap\n        .search(\"\", Scope::Base, \"(objectClass=*)\", vec![\"namingContexts\"])\n        .await\n        .map_err(|e| LdapError::QueryFailed(e.to_string()))?\n        .success()\n        .map_err(|e| LdapError::QueryFailed(e.to_string()))?;\n\n    let mut base_dns = Vec::new();\n\n    for entry in rs {\n        let entry = SearchEntry::construct(entry);\n        if let Some(contexts) = entry.attrs.get(\"namingContexts\") {\n            for context in contexts {\n                if !context.is_empty() {\n                    base_dns.push(context.clone());\n                }\n            }\n        }\n    }\n\n    let _ = ldap.unbind().await;\n\n    if base_dns.is_empty() {\n        warn!(\"No naming contexts found in rootDSE\");\n    } else {\n        info!(\"Discovered {} base DN(s): {:?}\", base_dns.len(), base_dns);\n    }\n\n    Ok(base_dns)\n}\n\nfn build_ldap_url(config: &LdapConfig) -> String {\n    let scheme = match (&config.tls_mode, config.port) {\n        (TlsMode::Disabled, _) => \"ldap\",\n        (_, 636) => \"ldaps\",\n        _ => \"ldap\",\n    };\n\n    format!(\"{}://{}:{}\", scheme, config.host, config.port)\n}\n"
  },
  {
    "path": "warpgate-ldap/src/error.rs",
    "content": "use thiserror::Error;\nuse warpgate_tls::RustlsSetupError;\n\npub type Result<T> = std::result::Result<T, LdapError>;\n\n#[derive(Error, Debug)]\npub enum LdapError {\n    #[error(\"LDAP connection failed: {0}\")]\n    ConnectionFailed(String),\n\n    #[error(\"LDAP authentication failed: {0}\")]\n    AuthenticationFailed(String),\n\n    #[error(\"LDAP query failed: {0}\")]\n    QueryFailed(String),\n\n    #[error(\"TLS error: {0}\")]\n    TlsError(String),\n\n    #[error(\"Invalid configuration: {0}\")]\n    InvalidConfiguration(String),\n\n    #[error(\"LDAP error: {0}\")]\n    LdapClientError(#[from] Box<ldap3::LdapError>),\n\n    #[error(\"JSON error: {0}\")]\n    JsonError(#[from] serde_json::Error),\n\n    #[error(\"rustls setup: {0}\")]\n    RustlSetup(#[from] RustlsSetupError),\n\n    #[error(\"cannot determine username for user DN: {0}\")]\n    NoUsername(String),\n\n    #[error(\"cannot determine UUID for user DN: {0}\")]\n    NoUUID(String),\n\n    #[error(\"Other error: {0}\")]\n    Other(String),\n}\n\nimpl From<String> for LdapError {\n    fn from(s: String) -> Self {\n        LdapError::Other(s)\n    }\n}\n"
  },
  {
    "path": "warpgate-ldap/src/lib.rs",
    "content": "mod connection;\nmod error;\nmod queries;\nmod types;\n\npub use connection::{connect, discover_base_dns, test_connection};\npub use error::{LdapError, Result};\npub use queries::{find_user_by_username, find_user_by_uuid, list_users};\npub use types::{LdapConfig, LdapUser, LdapUsernameAttribute};\n"
  },
  {
    "path": "warpgate-ldap/src/queries.rs",
    "content": "use std::collections::HashSet;\nuse std::fmt::Write;\n\nuse ldap3::{Scope, SearchEntry};\nuse tracing::{debug, warn};\nuse uuid::Uuid;\n\nuse crate::connection::connect;\nuse crate::error::{LdapError, Result};\nuse crate::types::{LdapConfig, LdapUser};\n\nfn ldap_user_attributes(config: &LdapConfig) -> Vec<String> {\n    let mut attrs: Vec<String> = vec![\n        \"mail\".into(),\n        \"displayName\".into(),\n        \"userPrincipalName\".into(),\n    ];\n\n    // Add UUID attributes - either custom or default ones\n    if let Some(custom_uuid_attr) = &config.uuid_attribute {\n        if !attrs.contains(custom_uuid_attr) {\n            attrs.push(custom_uuid_attr.clone());\n        }\n    } else {\n        // Default behavior: query both objectGUID and entryUUID\n        attrs.push(\"objectGUID\".into());\n        attrs.push(\"entryUUID\".into());\n    }\n\n    let username_attribute = config.username_attribute.attribute_name().to_string();\n    if !attrs.contains(&username_attribute) {\n        attrs.push(username_attribute);\n    }\n    if !attrs.contains(&config.ssh_key_attribute) {\n        attrs.push(config.ssh_key_attribute.clone());\n    }\n    attrs\n}\n\n/// Extract user details from an LDAP [SearchEntry].\n/// Returns None if no valid username or UUID can be determined.\nfn extract_ldap_user(search_entry: SearchEntry, config: &LdapConfig) -> Result<LdapUser> {\n    let dn = search_entry.dn.clone();\n\n    // Extract username - try different attributes\n    let username = search_entry\n        .attrs\n        .get(config.username_attribute.attribute_name())\n        .and_then(|v| v.first())\n        .cloned()\n        .ok_or(LdapError::NoUsername(dn.clone()))?;\n\n    let email = search_entry\n        .attrs\n        .get(\"mail\")\n        .and_then(|v| v.first())\n        .cloned();\n\n    let display_name = search_entry\n        .attrs\n        .get(\"displayName\")\n        .and_then(|v| v.first())\n        .cloned();\n\n    let object_uuid = if let Some(custom_uuid_attr) = &config.uuid_attribute {\n        // Try parsing as a binary UUID\n        search_entry\n            .bin_attrs\n            .get(custom_uuid_attr)\n            .and_then(|v: &Vec<Vec<u8>>| v.first())\n            .and_then(|b|\n                Uuid::from_slice(&b[..])\n                    .inspect_err(|e| {\n                        warn!(\"Failed to parse UUID {b:?} from LDAP attribute {custom_uuid_attr}: {e}\");\n                    })\n                    .ok())\n            .or_else(|| {\n                // Try parsing as a string UUID\n                search_entry\n                    .attrs\n                    .get(custom_uuid_attr)\n                    .and_then(|v| v.first())\n                    .and_then(|s| {\n                        Uuid::parse_str(s)\n                            .inspect_err(|e| {\n                                warn!(\"Failed to parse UUID {s} from LDAP attribute {custom_uuid_attr}: {e}\");\n                            })\n                            .ok()\n                    })\n            })\n    } else {\n        // Default behavior: Active Directory uses objectGUID, OpenLDAP uses entryUUID\n        search_entry\n            .bin_attrs\n            .get(\"objectGUID\")\n            .or_else(|| search_entry.bin_attrs.get(\"entryUUID\"))\n            .and_then(|v: &Vec<Vec<u8>>| v.first())\n            .and_then(|b| Uuid::from_slice(&b[..]).ok())\n    }\n    .ok_or(LdapError::NoUUID(dn.clone()))?;\n\n    // Extract SSH public keys\n    let ssh_public_keys = search_entry\n        .attrs\n        .get(&config.ssh_key_attribute)\n        .cloned()\n        .unwrap_or_default();\n\n    Ok(LdapUser {\n        username,\n        email,\n        display_name,\n        dn,\n        object_uuid,\n        ssh_public_keys,\n    })\n}\n\npub async fn list_users(config: &LdapConfig) -> Result<Vec<LdapUser>> {\n    let mut ldap = connect(config).await?;\n\n    let mut all_users = Vec::new();\n    let mut seen_dns = HashSet::new();\n\n    // Query each base DN\n    for base_dn in &config.base_dns {\n        debug!(\"Searching for users in base DN: {}\", base_dn);\n\n        let (rs, _res) = ldap\n            .search(\n                base_dn,\n                Scope::Subtree,\n                &config.user_filter,\n                &ldap_user_attributes(config),\n            )\n            .await\n            .map_err(|e| LdapError::QueryFailed(format!(\"Search failed in {}: {}\", base_dn, e)))?\n            .success()\n            .map_err(|e| LdapError::QueryFailed(format!(\"Search failed in {}: {}\", base_dn, e)))?;\n\n        for entry in rs {\n            let search_entry = SearchEntry::construct(entry);\n            let dn = search_entry.dn.clone();\n\n            // Skip duplicates (same DN might appear in multiple searches)\n            if seen_dns.contains(&dn) {\n                continue;\n            }\n            seen_dns.insert(dn.clone());\n\n            match extract_ldap_user(search_entry, config) {\n                Ok(user) => {\n                    all_users.push(user);\n                }\n                Err(e) => {\n                    warn!(\"Skipping LDAP user {dn}: {e}\");\n                    continue;\n                }\n            }\n        }\n    }\n\n    Ok(all_users)\n}\n\npub async fn find_user_by_username(\n    config: &LdapConfig,\n    username: &str,\n) -> Result<Option<LdapUser>> {\n    let mut ldap = connect(config).await?;\n\n    let filter = format!(\n        \"(&{}({}={}))\",\n        config.user_filter,\n        config.username_attribute.attribute_name(),\n        username\n    );\n\n    if let Some(user) = find_user_by_filter(&mut ldap, config, &filter).await? {\n        return Ok(Some(user));\n    }\n\n    debug!(\"No user found with username: {username}\");\n    Ok(None)\n}\n\nasync fn find_user_by_filter(\n    ldap: &mut ldap3::Ldap,\n    config: &LdapConfig,\n    filter: &str,\n) -> Result<Option<LdapUser>> {\n    debug!(\"Searching LDAP with filter: {filter}\");\n    for base_dn in &config.base_dns {\n        let (rs, _res) = ldap\n            .search(\n                base_dn,\n                Scope::Subtree,\n                filter,\n                vec![\"*\", \"+\"], // Request all user attributes (*) and operational attributes (+)\n            )\n            .await\n            .map_err(|e| LdapError::QueryFailed(e.to_string()))?\n            .success()\n            .map_err(|e| LdapError::QueryFailed(e.to_string()))?;\n\n        if !rs.is_empty() {\n            #[allow(clippy::unwrap_used, reason = \"length checked\")]\n            let search_entry = SearchEntry::construct(rs.into_iter().next().unwrap());\n\n            match extract_ldap_user(search_entry, config) {\n                Ok(user) => {\n                    debug!(\"Found LDAP user with filter {filter}: {user:?}\");\n                    return Ok(Some(user));\n                }\n                Err(e) => {\n                    warn!(\"LDAP result extraction failed for filter {filter}: {e}\");\n                    continue;\n                }\n            }\n        }\n    }\n    Ok(None)\n}\n\npub async fn find_user_by_uuid(\n    config: &LdapConfig,\n    object_uuid: &Uuid,\n) -> Result<Option<LdapUser>> {\n    let mut ldap = connect(config).await?;\n\n    // Convert UUID to different formats for searching\n    // OpenLDAP uses standard UUID string format (with dashes)\n    let uuid_str = object_uuid.to_string();\n\n    // Active Directory stores objectGUID as binary and requires hex encoding in filters\n    // Convert UUID bytes to escaped hex string for LDAP filter (e.g., \\01\\02\\03...)\n    let binary_guid_str = {\n        let uuid_bytes = object_uuid.as_bytes();\n        uuid_bytes.iter().fold(String::new(), |mut s, b| {\n            let _ = write!(&mut s, \"\\\\{:02x}\", b);\n            s\n        })\n    };\n\n    let user_filter = &config.user_filter;\n\n    if let Some(custom_uuid_attr) = &config.uuid_attribute {\n        // Note: the reason for doing multiple separate requests is for `lldap` compatibility\n        // lldap does not support queries with non-UTF8 attribute values and fails if\n        // we try to query with multiple values OR'ed\n\n        if let Some(user) = find_user_by_filter(\n            &mut ldap,\n            config,\n            &format!(\"(&{user_filter}({custom_uuid_attr}={uuid_str}))\"),\n        )\n        .await?\n        {\n            return Ok(Some(user));\n        }\n\n        // Active Directory\n        if let Some(user) = find_user_by_filter(\n            &mut ldap,\n            config,\n            &format!(\"(&{user_filter}({custom_uuid_attr}={binary_guid_str}))\"),\n        )\n        .await?\n        {\n            return Ok(Some(user));\n        }\n    } else {\n        if let Some(user) = find_user_by_filter(\n            &mut ldap,\n            config,\n            // OpenLDAP style\n            &format!(\"(&{user_filter}(entryUUID={uuid_str}))\"),\n        )\n        .await?\n        {\n            return Ok(Some(user));\n        }\n\n        if let Some(user) = find_user_by_filter(\n            &mut ldap,\n            config,\n            &format!(\"(&{user_filter}(objectGUID={uuid_str}))\"),\n        )\n        .await?\n        {\n            return Ok(Some(user));\n        }\n\n        if let Some(user) = find_user_by_filter(\n            &mut ldap,\n            config,\n            // Active Directory\n            &format!(\"(&{user_filter}(objectGUID={binary_guid_str}))\"),\n        )\n        .await?\n        {\n            return Ok(Some(user));\n        }\n    }\n\n    debug!(\"No user found with UUID: {}\", object_uuid);\n    Ok(None)\n}\n"
  },
  {
    "path": "warpgate-ldap/src/types.rs",
    "content": "use poem_openapi::Enum;\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\nuse warpgate_tls::TlsMode;\n\n#[derive(Debug, Clone, Copy, Enum)]\npub enum LdapUsernameAttribute {\n    Cn,\n    Uid,\n    Email,\n    UserPrincipalName,\n    SamAccountName,\n}\n\nimpl LdapUsernameAttribute {\n    pub fn attribute_name(&self) -> &'static str {\n        match self {\n            LdapUsernameAttribute::Cn => \"cn\",\n            LdapUsernameAttribute::Uid => \"uid\",\n            LdapUsernameAttribute::Email => \"mail\",\n            LdapUsernameAttribute::UserPrincipalName => \"userPrincipalName\",\n            LdapUsernameAttribute::SamAccountName => \"sAMAccountName\",\n        }\n    }\n}\n\nimpl TryFrom<&str> for LdapUsernameAttribute {\n    type Error = ();\n\n    fn try_from(value: &str) -> Result<Self, Self::Error> {\n        Ok(match value {\n            \"cn\" => Self::Cn,\n            \"uid\" => Self::Uid,\n            \"mail\" => Self::Email,\n            \"userPrincipalName\" => Self::UserPrincipalName,\n            \"sAMAccountName\" => Self::SamAccountName,\n            _ => return Err(()),\n        })\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct LdapConfig {\n    pub host: String,\n    pub port: u16,\n    pub bind_dn: String,\n    pub bind_password: String,\n    pub tls_mode: TlsMode,\n    pub tls_verify: bool,\n    pub base_dns: Vec<String>,\n    pub user_filter: String,\n    pub username_attribute: LdapUsernameAttribute,\n    pub ssh_key_attribute: String,\n    pub uuid_attribute: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct LdapUser {\n    pub username: String,\n    pub email: Option<String>,\n    pub display_name: Option<String>,\n    pub dn: String,\n    pub object_uuid: Uuid,\n    pub ssh_public_keys: Vec<String>,\n}\n"
  },
  {
    "path": "warpgate-protocol-http/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-protocol-http\"\nversion = \"0.22.0\"\n\n[dependencies]\nanyhow = \"1.0\"\nasync-trait = \"0.1\"\nchrono = { version = \"0.4\", default-features = false, features = [\"serde\"] }\ncookie = \"0.18\"\ndata-encoding.workspace = true\ndelegate.workspace = true\nfutures.workspace = true\nhttp = { version = \"1.0\", default-features = false }\nonce_cell = { version = \"1.17\", default-features = false }\npoem.workspace = true\npoem-openapi.workspace = true\nreqwest.workspace = true\nsea-orm.workspace = true\nserde.workspace = true\nserde_json.workspace = true\ntokio.workspace = true\ntokio-tungstenite.workspace = true\ntracing.workspace = true\nwarpgate-admin = { version = \"*\", path = \"../warpgate-admin\", default-features = false }\nwarpgate-common = { version = \"*\", path = \"../warpgate-common\", default-features = false }\nwarpgate-common-http = { path = \"../warpgate-common-http\" }\nwarpgate-ca = { version = \"*\", path = \"../warpgate-ca\", default-features = false }\nwarpgate-tls = { version = \"*\", path = \"../warpgate-tls\", default-features = false }\nwarpgate-core = { version = \"*\", path = \"../warpgate-core\", default-features = false }\nwarpgate-db-entities = { version = \"*\", path = \"../warpgate-db-entities\", default-features = false }\nwarpgate-web = { version = \"*\", path = \"../warpgate-web\", default-features = false }\nwarpgate-sso = { version = \"*\", path = \"../warpgate-sso\", default-features = false }\npercent-encoding = { version = \"2.1\", default-features = false }\nuuid.workspace = true\nregex.workspace = true\nurl = { version = \"2.4\", default-features = false }\n"
  },
  {
    "path": "warpgate-protocol-http/src/api/api_tokens.rs",
    "content": "use chrono::{DateTime, Utc};\nuse poem::web::Data;\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{ActiveModelTrait, ColumnTrait, ModelTrait, QueryFilter, Set};\nuse uuid::Uuid;\nuse warpgate_common::helpers::hash::generate_ticket_secret;\nuse warpgate_common::WarpgateError;\nuse warpgate_common_http::auth::AuthenticatedRequestContext;\nuse warpgate_db_entities::ApiToken;\n\nuse super::common::get_user;\nuse crate::common::endpoint_auth;\n\npub struct Api;\n\n#[derive(ApiResponse)]\nenum GetApiTokensResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<ExistingApiToken>>),\n    #[oai(status = 401)]\n    Unauthorized,\n}\n\n#[derive(Object)]\nstruct NewApiToken {\n    label: String,\n    expiry: DateTime<Utc>,\n}\n\n#[derive(Object)]\nstruct ExistingApiToken {\n    id: Uuid,\n    label: String,\n    created: DateTime<Utc>,\n    expiry: DateTime<Utc>,\n}\n\nimpl From<ApiToken::Model> for ExistingApiToken {\n    fn from(token: ApiToken::Model) -> Self {\n        Self {\n            id: token.id,\n            label: token.label,\n            created: token.created,\n            expiry: token.expiry,\n        }\n    }\n}\n\n#[derive(Object)]\nstruct TokenAndSecret {\n    token: ExistingApiToken,\n    secret: String,\n}\n\n#[derive(ApiResponse)]\nenum CreateApiTokenResponse {\n    #[oai(status = 201)]\n    Created(Json<TokenAndSecret>),\n    #[oai(status = 401)]\n    Unauthorized,\n}\n\n#[derive(ApiResponse)]\nenum DeleteApiTokenResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 401)]\n    Unauthorized,\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/profile/api-tokens\",\n        method = \"get\",\n        operation_id = \"get_my_api_tokens\",\n        transform = \"endpoint_auth\"\n    )]\n    async fn api_get_api_tokens(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n    ) -> Result<GetApiTokensResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(GetApiTokensResponse::Unauthorized);\n        };\n\n        let api_tokens = user_model.find_related(ApiToken::Entity).all(&*db).await?;\n\n        Ok(GetApiTokensResponse::Ok(Json(\n            api_tokens.into_iter().map(Into::into).collect(),\n        )))\n    }\n\n    #[oai(\n        path = \"/profile/api-tokens\",\n        method = \"post\",\n        operation_id = \"create_api_token\",\n        transform = \"endpoint_auth\"\n    )]\n    async fn api_create_api_token(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<NewApiToken>,\n    ) -> Result<CreateApiTokenResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(CreateApiTokenResponse::Unauthorized);\n        };\n\n        let secret = generate_ticket_secret();\n        let object = ApiToken::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(user_model.id),\n            created: Set(Utc::now()),\n            expiry: Set(body.expiry),\n            label: Set(body.label.clone()),\n            secret: Set(secret.expose_secret().to_string()),\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        Ok(CreateApiTokenResponse::Created(Json(TokenAndSecret {\n            token: object.into(),\n            secret: secret.expose_secret().to_string(),\n        })))\n    }\n\n    #[oai(\n        path = \"/profile/api-tokens/:id\",\n        method = \"delete\",\n        operation_id = \"delete_my_api_token\",\n        transform = \"endpoint_auth\"\n    )]\n    async fn api_delete_api_token(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n    ) -> Result<DeleteApiTokenResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(DeleteApiTokenResponse::Unauthorized);\n        };\n\n        let Some(model) = user_model\n            .find_related(ApiToken::Entity)\n            .filter(ApiToken::Column::Id.eq(id.0))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(DeleteApiTokenResponse::NotFound);\n        };\n\n        model.delete(&*db).await?;\n        Ok(DeleteApiTokenResponse::Deleted)\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/api/auth.rs",
    "content": "use std::ops::DerefMut;\nuse std::sync::Arc;\n\nuse anyhow::bail;\nuse chrono::{DateTime, Utc};\nuse futures::{SinkExt, StreamExt};\nuse poem::session::Session;\nuse poem::web::websocket::{Message, WebSocket};\nuse poem::web::Data;\nuse poem::{handler, IntoResponse, Request};\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Enum, Object, OpenApi};\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_admin::api::AnySecurityScheme;\nuse warpgate_common::auth::{AuthCredential, AuthResult, AuthState, CredentialKind};\nuse warpgate_common::{Secret, WarpgateError};\nuse warpgate_common_http::auth::{AuthenticatedRequestContext, UnauthenticatedRequestContext};\nuse warpgate_common_http::{RequestAuthorization, SessionAuthorization};\nuse warpgate_core::{ConfigProvider, Services};\n\nuse super::common::logout;\nuse crate::common::{authorize_session, endpoint_auth, get_auth_state_for_request, SessionExt};\nuse crate::session::SessionStore;\npub struct Api;\n\n#[derive(Object)]\nstruct LoginRequest {\n    username: String,\n    password: String,\n}\n\n#[derive(Object)]\nstruct OtpLoginRequest {\n    otp: String,\n}\n\n#[derive(Enum)]\nenum ApiAuthState {\n    NotStarted,\n    Failed,\n    PasswordNeeded,\n    OtpNeeded,\n    SsoNeeded,\n    WebUserApprovalNeeded,\n    PublicKeyNeeded,\n    Success,\n}\n\n#[derive(Object)]\nstruct LoginFailureResponse {\n    state: ApiAuthState,\n}\n\n#[derive(ApiResponse)]\nenum LoginResponse {\n    #[oai(status = 201)]\n    Success,\n\n    #[oai(status = 401)]\n    Failure(Json<LoginFailureResponse>),\n}\n\n#[derive(ApiResponse)]\nenum LogoutResponse {\n    #[oai(status = 201)]\n    Success,\n}\n\n#[derive(Object)]\nstruct AuthStateResponseInternal {\n    pub id: String,\n    pub protocol: String,\n    pub address: Option<String>,\n    pub started: DateTime<Utc>,\n    pub state: ApiAuthState,\n    pub identification_string: String,\n}\n\n#[derive(ApiResponse)]\nenum AuthStateListResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<AuthStateResponseInternal>>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(ApiResponse)]\nenum AuthStateResponse {\n    #[oai(status = 200)]\n    Ok(Json<AuthStateResponseInternal>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\nconst PREFERRED_NEED_CRED_ORDER: &[CredentialKind] = &[\n    CredentialKind::PublicKey,\n    CredentialKind::Password,\n    CredentialKind::Totp,\n    CredentialKind::Sso,\n    CredentialKind::WebUserApproval,\n];\n\nimpl From<AuthResult> for ApiAuthState {\n    fn from(state: AuthResult) -> Self {\n        match state {\n            AuthResult::Rejected => ApiAuthState::Failed,\n            AuthResult::Need(kinds) => {\n                let kind = PREFERRED_NEED_CRED_ORDER\n                    .iter()\n                    .find(|x| kinds.contains(x))\n                    .or(kinds.iter().next());\n                match kind {\n                    Some(CredentialKind::Password) => ApiAuthState::PasswordNeeded,\n                    Some(CredentialKind::Totp) => ApiAuthState::OtpNeeded,\n                    Some(CredentialKind::Sso) => ApiAuthState::SsoNeeded,\n                    Some(CredentialKind::WebUserApproval) => ApiAuthState::WebUserApprovalNeeded,\n                    Some(CredentialKind::PublicKey) => ApiAuthState::PublicKeyNeeded,\n                    Some(CredentialKind::Certificate) => {\n                        // Certificate authentication is not supported for HTTP protocol\n                        // This credential type is primarily for Kubernetes\n                        ApiAuthState::Failed\n                    }\n                    None => ApiAuthState::Failed,\n                }\n            }\n            AuthResult::Accepted { .. } => ApiAuthState::Success,\n        }\n    }\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(path = \"/auth/login\", method = \"post\", operation_id = \"login\")]\n    async fn api_auth_login(\n        &self,\n        req: &Request,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n        body: Json<LoginRequest>,\n    ) -> poem::Result<LoginResponse> {\n        let services = &ctx.services;\n        let mut auth_state_store = services.auth_state_store.lock().await;\n        let state_arc = match get_auth_state_for_request(\n            &body.username,\n            session,\n            &mut auth_state_store,\n        )\n        .await\n        {\n            Err(WarpgateError::UserNotFound(_)) => {\n                return Ok(LoginResponse::Failure(Json(LoginFailureResponse {\n                    state: ApiAuthState::Failed,\n                })))\n            }\n            x => x,\n        }?;\n        let mut state = state_arc.lock().await;\n\n        let mut cp = services.config_provider.lock().await;\n\n        let password_cred = AuthCredential::Password(Secret::new(body.password.clone()));\n        if cp\n            .validate_credential(&state.user_info().username, &password_cred)\n            .await?\n        {\n            state.add_valid_credential(password_cred);\n        }\n\n        match state.verify() {\n            AuthResult::Accepted { user_info } => {\n                auth_state_store.complete(state.id()).await;\n                authorize_session(req, &ctx, user_info).await?;\n                Ok(LoginResponse::Success)\n            }\n            x => {\n                error!(\"Auth rejected\");\n                Ok(LoginResponse::Failure(Json(LoginFailureResponse {\n                    state: x.into(),\n                })))\n            }\n        }\n    }\n\n    #[oai(path = \"/auth/otp\", method = \"post\", operation_id = \"otpLogin\")]\n    async fn api_auth_otp_login(\n        &self,\n        req: &Request,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n        body: Json<OtpLoginRequest>,\n    ) -> poem::Result<LoginResponse> {\n        let services = &ctx.services;\n        let state_id = session.get_auth_state_id();\n\n        let mut auth_state_store = services.auth_state_store.lock().await;\n\n        let Some(state_arc) = state_id.and_then(|id| auth_state_store.get(&id.0)) else {\n            return Ok(LoginResponse::Failure(Json(LoginFailureResponse {\n                state: ApiAuthState::NotStarted,\n            })));\n        };\n\n        let mut state = state_arc.lock().await;\n\n        let mut cp = services.config_provider.lock().await;\n\n        let otp_cred = AuthCredential::Otp(body.otp.clone().into());\n        if cp\n            .validate_credential(&state.user_info().username, &otp_cred)\n            .await?\n        {\n            state.add_valid_credential(otp_cred);\n        } else {\n            warn!(\"Invalid OTP for user {}\", state.user_info().username);\n        }\n\n        match state.verify() {\n            AuthResult::Accepted { user_info } => {\n                auth_state_store.complete(state.id()).await;\n                authorize_session(req, &ctx, user_info).await?;\n                Ok(LoginResponse::Success)\n            }\n            x => Ok(LoginResponse::Failure(Json(LoginFailureResponse {\n                state: x.into(),\n            }))),\n        }\n    }\n\n    #[oai(path = \"/auth/logout\", method = \"post\", operation_id = \"logout\")]\n    async fn api_auth_logout(\n        &self,\n        session: &Session,\n        session_middleware: Data<&Arc<Mutex<SessionStore>>>,\n    ) -> poem::Result<LogoutResponse> {\n        logout(session, session_middleware.lock().await.deref_mut());\n        Ok(LogoutResponse::Success)\n    }\n\n    #[oai(\n        path = \"/auth/state\",\n        method = \"get\",\n        operation_id = \"get_default_auth_state\"\n    )]\n    async fn api_default_auth_state(\n        &self,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n    ) -> poem::Result<AuthStateResponse> {\n        let services = &ctx.services;\n        let Some(state_id) = session.get_auth_state_id() else {\n            return Ok(AuthStateResponse::NotFound);\n        };\n        let store = services.auth_state_store.lock().await;\n        let Some(state_arc) = store.get(&state_id.0) else {\n            return Ok(AuthStateResponse::NotFound);\n        };\n        serialize_auth_state_inner(state_arc, services)\n            .await\n            .map(Json)\n            .map(AuthStateResponse::Ok)\n    }\n\n    #[oai(\n        path = \"/auth/state\",\n        method = \"delete\",\n        operation_id = \"cancel_default_auth\"\n    )]\n    async fn api_cancel_default_auth(\n        &self,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n    ) -> poem::Result<AuthStateResponse> {\n        let services = &ctx.services;\n        let Some(state_id) = session.get_auth_state_id() else {\n            return Ok(AuthStateResponse::NotFound);\n        };\n        let mut store = services.auth_state_store.lock().await;\n        let Some(state_arc) = store.get(&state_id.0) else {\n            return Ok(AuthStateResponse::NotFound);\n        };\n        state_arc.lock().await.reject();\n        store.complete(&state_id.0).await;\n        session.clear_auth_state();\n\n        serialize_auth_state_inner(state_arc, services)\n            .await\n            .map(Json)\n            .map(AuthStateResponse::Ok)\n    }\n\n    #[oai(\n        path = \"/auth/web-auth-requests\",\n        method = \"get\",\n        operation_id = \"get_web_auth_requests\",\n        transform = \"endpoint_auth\"\n    )]\n    async fn get_web_auth_requests(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> poem::Result<AuthStateListResponse> {\n        let services = &ctx.services;\n        let store = services.auth_state_store.lock().await;\n\n        let RequestAuthorization::Session(SessionAuthorization::User(ref username)) = ctx.auth\n        else {\n            return Ok(AuthStateListResponse::NotFound);\n        };\n\n        let state_arcs = store.all_pending_web_auths_for_user(username).await;\n\n        let mut results = vec![];\n\n        for state_arc in state_arcs {\n            results.push(serialize_auth_state_inner(state_arc, services).await?)\n        }\n\n        Ok(AuthStateListResponse::Ok(Json(results)))\n    }\n\n    #[oai(\n        path = \"/auth/state/:id\",\n        method = \"get\",\n        operation_id = \"get_auth_state\",\n        transform = \"endpoint_auth\"\n    )]\n    async fn api_auth_state(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n    ) -> poem::Result<AuthStateResponse> {\n        let services = &ctx.services;\n        let state_arc = get_auth_state(&id, &ctx).await;\n        let Some(state_arc) = state_arc else {\n            return Ok(AuthStateResponse::NotFound);\n        };\n        serialize_auth_state_inner(state_arc, services)\n            .await\n            .map(Json)\n            .map(AuthStateResponse::Ok)\n    }\n\n    #[oai(\n        path = \"/auth/state/:id/approve\",\n        method = \"post\",\n        operation_id = \"approve_auth\",\n        transform = \"endpoint_auth\"\n    )]\n    async fn api_approve_auth(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> poem::Result<AuthStateResponse> {\n        let services = &ctx.services;\n        let Some(state_arc) = get_auth_state(&id, &ctx).await else {\n            return Ok(AuthStateResponse::NotFound);\n        };\n\n        let auth_result = {\n            let mut state = state_arc.lock().await;\n            state.add_valid_credential(AuthCredential::WebUserApproval);\n            state.verify()\n        };\n\n        if let AuthResult::Accepted { .. } = auth_result {\n            let mut store = services.auth_state_store.lock().await;\n            store.complete(&id).await;\n        }\n        serialize_auth_state_inner(state_arc, services)\n            .await\n            .map(Json)\n            .map(AuthStateResponse::Ok)\n    }\n\n    #[oai(\n        path = \"/auth/state/:id/reject\",\n        method = \"post\",\n        operation_id = \"reject_auth\",\n        transform = \"endpoint_auth\"\n    )]\n    async fn api_reject_auth(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> poem::Result<AuthStateResponse> {\n        let services = &ctx.services;\n        let Some(state_arc) = get_auth_state(&id, &ctx).await else {\n            return Ok(AuthStateResponse::NotFound);\n        };\n        state_arc.lock().await.reject();\n        services.auth_state_store.lock().await.complete(&id).await;\n        serialize_auth_state_inner(state_arc, services)\n            .await\n            .map(Json)\n            .map(AuthStateResponse::Ok)\n    }\n}\n\nasync fn get_auth_state(\n    id: &Uuid,\n    ctx: &AuthenticatedRequestContext,\n) -> Option<Arc<Mutex<AuthState>>> {\n    let store = ctx.services.auth_state_store.lock().await;\n\n    let RequestAuthorization::Session(SessionAuthorization::User(username)) = &ctx.auth else {\n        return None;\n    };\n\n    let state_arc = store.get(id)?;\n\n    {\n        let state = state_arc.lock().await;\n        if &state.user_info().username != username {\n            return None;\n        }\n    }\n\n    Some(state_arc)\n}\n\nasync fn serialize_auth_state_inner(\n    state_arc: Arc<Mutex<AuthState>>,\n    services: &Services,\n) -> poem::Result<AuthStateResponseInternal> {\n    let state = state_arc.lock().await;\n\n    let session_state_store = services.state.lock().await;\n    let session_state = state\n        .session_id()\n        .and_then(|session_id| session_state_store.sessions.get(&session_id));\n\n    let peer_addr = match session_state {\n        Some(x) => x.lock().await.remote_address,\n        None => None,\n    };\n\n    Ok(AuthStateResponseInternal {\n        id: state.id().to_string(),\n        protocol: state.protocol().to_string(),\n        address: peer_addr.map(|x| x.ip().to_string()),\n        started: *state.started(),\n        state: state.verify().into(),\n        identification_string: state.identification_string().to_owned(),\n    })\n}\n\n#[handler]\npub async fn api_get_web_auth_requests_stream(\n    ws: WebSocket,\n    ctx: Data<&AuthenticatedRequestContext>,\n) -> anyhow::Result<impl IntoResponse> {\n    let services = &ctx.services;\n    let auth_state_store = services.auth_state_store.clone();\n\n    let username = match ctx.auth {\n        RequestAuthorization::Session(SessionAuthorization::User(ref username)) => username.clone(),\n        _ => bail!(\"Only session-based user auth is supported for this endpoint\"),\n    };\n\n    let mut rx = {\n        let mut s = auth_state_store.lock().await;\n        s.subscribe_web_auth_request()\n    };\n\n    Ok(ws.on_upgrade(|socket| async move {\n        let (mut sink, _) = socket.split();\n\n        while let Ok(id) = rx.recv().await {\n            let auth_state_store = auth_state_store.lock().await;\n            if let Some(state) = auth_state_store.get(&id) {\n                let state = state.lock().await;\n                if state.user_info().username == username {\n                    sink.send(Message::Text(id.to_string())).await?;\n                }\n            }\n        }\n\n        Ok::<(), anyhow::Error>(())\n    }))\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/api/common.rs",
    "content": "use poem::session::Session;\nuse sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};\nuse tracing::info;\nuse warpgate_common::WarpgateError;\nuse warpgate_common_http::RequestAuthorization;\nuse warpgate_db_entities as entities;\n\nuse crate::session::SessionStore;\n\npub fn logout(session: &Session, session_middleware: &mut SessionStore) {\n    session_middleware.remove_session(session);\n    session.clear();\n    info!(\"Logged out\");\n}\n\npub async fn get_user(\n    auth: &RequestAuthorization,\n    db: &DatabaseConnection,\n) -> Result<Option<entities::User::Model>, WarpgateError> {\n    let Some(username) = auth.username() else {\n        return Ok(None);\n    };\n\n    let Some(user_model) = entities::User::Entity::find()\n        .filter(entities::User::Column::Username.eq(username))\n        .one(db)\n        .await?\n    else {\n        return Ok(None);\n    };\n\n    Ok(Some(user_model))\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/api/credentials.rs",
    "content": "use chrono::{DateTime, Utc};\nuse http::StatusCode;\nuse poem::web::Data;\nuse poem::{Endpoint, EndpointExt, FromRequest, IntoResponse};\nuse poem_openapi::param::Path;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Enum, Object, OpenApi};\nuse sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, Set};\nuse uuid::Uuid;\nuse warpgate_common::{User, UserPasswordCredential, UserRequireCredentialsPolicy, WarpgateError};\nuse warpgate_common_http::auth::{AuthenticatedRequestContext, UnauthenticatedRequestContext};\nuse warpgate_db_entities::{\n    self as entities, CertificateCredential, Parameters, PasswordCredential, PublicKeyCredential,\n};\n\nuse super::common::get_user;\nuse crate::api::AnySecurityScheme;\nuse crate::common::endpoint_auth;\n\npub struct Api;\n\n#[derive(Enum)]\nenum PasswordState {\n    Unset,\n    Set,\n    MultipleSet,\n}\n\n#[derive(Object)]\nstruct ExistingSsoCredential {\n    id: Uuid,\n    provider: Option<String>,\n    email: String,\n}\n\nimpl From<entities::SsoCredential::Model> for ExistingSsoCredential {\n    fn from(credential: entities::SsoCredential::Model) -> Self {\n        Self {\n            id: credential.id,\n            provider: credential.provider,\n            email: credential.email,\n        }\n    }\n}\n\n#[derive(Object)]\nstruct ChangePasswordRequest {\n    password: String,\n}\n\n#[derive(ApiResponse)]\nenum ChangePasswordResponse {\n    #[oai(status = 201)]\n    Done(Json<PasswordState>),\n    #[oai(status = 401)]\n    Unauthorized,\n}\n\n#[derive(Object)]\npub struct CredentialsState {\n    password: PasswordState,\n    otp: Vec<ExistingOtpCredential>,\n    public_keys: Vec<ExistingPublicKeyCredential>,\n    certificates: Vec<ExistingCertificateCredential>,\n    sso: Vec<ExistingSsoCredential>,\n    credential_policy: UserRequireCredentialsPolicy,\n    ldap_linked: bool,\n}\n\n#[derive(ApiResponse)]\n#[allow(clippy::large_enum_variant)]\nenum CredentialsStateResponse {\n    #[oai(status = 200)]\n    Ok(Json<CredentialsState>),\n    #[oai(status = 401)]\n    Unauthorized,\n}\n\n#[derive(Object)]\nstruct NewPublicKeyCredential {\n    label: String,\n    openssh_public_key: String,\n}\n\n#[derive(Object)]\nstruct ExistingPublicKeyCredential {\n    id: Uuid,\n    label: String,\n    date_added: Option<DateTime<Utc>>,\n    last_used: Option<DateTime<Utc>>,\n    abbreviated: String,\n}\n\nfn abbreviate_public_key(k: &str) -> String {\n    let l = 10;\n    if k.len() <= l {\n        return k.to_string(); // Return the full key if it's shorter than or equal to `l`.\n    }\n\n    format!(\n        \"{}...{}\",\n        &k[..l.min(k.len())],            // Take the first `l` characters.\n        &k[k.len().saturating_sub(l)..]  // Take the last `l` characters safely.\n    )\n}\n\nimpl From<entities::PublicKeyCredential::Model> for ExistingPublicKeyCredential {\n    fn from(credential: entities::PublicKeyCredential::Model) -> Self {\n        Self {\n            id: credential.id,\n            label: credential.label,\n            date_added: credential.date_added,\n            last_used: credential.last_used,\n            abbreviated: abbreviate_public_key(&credential.openssh_public_key),\n        }\n    }\n}\n#[derive(ApiResponse)]\nenum CreatePublicKeyCredentialResponse {\n    #[oai(status = 201)]\n    Created(Json<ExistingPublicKeyCredential>),\n    #[oai(status = 401)]\n    Unauthorized,\n}\n\n#[derive(ApiResponse)]\nenum DeleteCredentialResponse {\n    #[oai(status = 204)]\n    Deleted,\n    #[oai(status = 401)]\n    Unauthorized,\n    #[oai(status = 404)]\n    NotFound,\n}\n\n#[derive(Object)]\nstruct NewOtpCredential {\n    secret_key: Vec<u8>,\n}\n\n#[derive(Object)]\nstruct ExistingOtpCredential {\n    id: Uuid,\n}\n\nimpl From<entities::OtpCredential::Model> for ExistingOtpCredential {\n    fn from(credential: entities::OtpCredential::Model) -> Self {\n        Self { id: credential.id }\n    }\n}\n\n#[derive(ApiResponse)]\nenum CreateOtpCredentialResponse {\n    #[oai(status = 201)]\n    Created(Json<ExistingOtpCredential>),\n    #[oai(status = 401)]\n    Unauthorized,\n}\n\n#[derive(Object)]\nstruct ExistingCertificateCredential {\n    id: Uuid,\n    label: String,\n    date_added: Option<DateTime<Utc>>,\n    last_used: Option<DateTime<Utc>>,\n    fingerprint: String,\n}\n\nfn certificate_fingerprint(certificate_pem: &str) -> Result<String, WarpgateError> {\n    Ok(warpgate_ca::certificate_sha256_hex_fingerprint(\n        &warpgate_ca::deserialize_certificate(certificate_pem)?,\n    )?)\n}\n\nimpl From<entities::CertificateCredential::Model> for ExistingCertificateCredential {\n    fn from(credential: entities::CertificateCredential::Model) -> Self {\n        Self {\n            id: credential.id,\n            label: credential.label,\n            date_added: credential.date_added,\n            last_used: credential.last_used,\n            fingerprint: certificate_fingerprint(&credential.certificate_pem)\n                .unwrap_or_else(|_| \"Invalid certificate\".into()),\n        }\n    }\n}\n\n#[derive(Object)]\nstruct IssuedCertificateCredential {\n    credential: ExistingCertificateCredential,\n    certificate_pem: String,\n}\n\n#[derive(Object)]\nstruct IssueCertificateCredentialRequest {\n    label: String,\n    public_key_pem: String,\n}\n\n#[derive(ApiResponse)]\nenum IssueCertificateCredentialResponse {\n    #[oai(status = 201)]\n    Issued(Json<IssuedCertificateCredential>),\n    #[oai(status = 401)]\n    Unauthorized,\n}\n\n#[derive(ApiResponse)]\nenum DeleteCertificateCredentialResponse {\n    #[oai(status = 200)]\n    Ok,\n    #[oai(status = 401)]\n    Unauthorized,\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub fn parameters_based_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {\n    e.around(|ep, req| async move {\n        let ctx = Data::<&UnauthenticatedRequestContext>::from_request_without_body(&req).await?;\n        let services = &ctx.services;\n        let parameters = Parameters::Entity::get(&*services.db.lock().await)\n            .await\n            .map_err(WarpgateError::from)?;\n        if !parameters.allow_own_credential_management {\n            return Ok(poem::Response::builder()\n                .status(StatusCode::FORBIDDEN)\n                .body(\"Credential management is disabled\")\n                .into_response());\n        }\n        Ok(endpoint_auth(ep).call(req).await?.into_response())\n    })\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/profile/credentials\",\n        method = \"get\",\n        operation_id = \"get_my_credentials\",\n        transform = \"parameters_based_auth\"\n    )]\n    async fn api_get_credentials_state(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CredentialsStateResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(CredentialsStateResponse::Unauthorized);\n        };\n\n        let user = User::try_from(user_model.clone())?;\n\n        let otp_creds = user_model\n            .find_related(entities::OtpCredential::Entity)\n            .all(&*db)\n            .await?;\n        let password_creds = user_model\n            .find_related(entities::PasswordCredential::Entity)\n            .all(&*db)\n            .await?;\n        let sso_creds = user_model\n            .find_related(entities::SsoCredential::Entity)\n            .all(&*db)\n            .await?;\n\n        let pk_creds = user_model\n            .find_related(entities::PublicKeyCredential::Entity)\n            .all(&*db)\n            .await?;\n\n        let cert_creds = user_model\n            .find_related(entities::CertificateCredential::Entity)\n            .all(&*db)\n            .await?;\n\n        Ok(CredentialsStateResponse::Ok(Json(CredentialsState {\n            password: match password_creds.len() {\n                0 => PasswordState::Unset,\n                1 => PasswordState::Set,\n                _ => PasswordState::MultipleSet,\n            },\n            otp: otp_creds.into_iter().map(Into::into).collect(),\n            public_keys: pk_creds.into_iter().map(Into::into).collect(),\n            certificates: cert_creds.into_iter().map(Into::into).collect(),\n            sso: sso_creds.into_iter().map(Into::into).collect(),\n            credential_policy: user.credential_policy.unwrap_or_default(),\n            ldap_linked: user_model.ldap_server_id.is_some(),\n        })))\n    }\n\n    #[oai(\n        path = \"/profile/credentials/password\",\n        method = \"post\",\n        operation_id = \"change_my_password\",\n        transform = \"parameters_based_auth\"\n    )]\n    async fn api_change_password(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<ChangePasswordRequest>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<ChangePasswordResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(ChangePasswordResponse::Unauthorized);\n        };\n\n        entities::PasswordCredential::Entity::delete_many()\n            .filter(entities::PasswordCredential::Column::UserId.eq(user_model.id))\n            .exec(&*db)\n            .await\n            .map_err(WarpgateError::from)?;\n\n        let new_credential = entities::PasswordCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(user_model.id),\n            ..PasswordCredential::ActiveModel::from(UserPasswordCredential::from_password(\n                &body.password.clone().into(),\n            ))\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        entities::PasswordCredential::Entity::find()\n            .filter(\n                entities::PasswordCredential::Column::UserId\n                    .eq(user_model.id)\n                    .and(entities::PasswordCredential::Column::Id.ne(new_credential.id)),\n            )\n            .all(&*db)\n            .await?;\n\n        Ok(ChangePasswordResponse::Done(Json(PasswordState::Set)))\n    }\n\n    #[oai(\n        path = \"/profile/credentials/public-keys\",\n        method = \"post\",\n        operation_id = \"add_my_public_key\",\n        transform = \"parameters_based_auth\"\n    )]\n    async fn api_create_pk(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<NewPublicKeyCredential>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreatePublicKeyCredentialResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(CreatePublicKeyCredentialResponse::Unauthorized);\n        };\n\n        let object = PublicKeyCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(user_model.id),\n            date_added: Set(Some(Utc::now())),\n            last_used: Set(None),\n            label: Set(body.label.clone()),\n            openssh_public_key: Set(body.openssh_public_key.clone()),\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        Ok(CreatePublicKeyCredentialResponse::Created(Json(\n            object.into(),\n        )))\n    }\n\n    #[oai(\n        path = \"/profile/credentials/public-keys/:id\",\n        method = \"delete\",\n        operation_id = \"delete_my_public_key\",\n        transform = \"parameters_based_auth\"\n    )]\n    async fn api_delete_pk(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteCredentialResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(DeleteCredentialResponse::Unauthorized);\n        };\n\n        let Some(model) = user_model\n            .find_related(entities::PublicKeyCredential::Entity)\n            .filter(entities::PublicKeyCredential::Column::Id.eq(id.0))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(DeleteCredentialResponse::NotFound);\n        };\n\n        model.delete(&*db).await?;\n        Ok(DeleteCredentialResponse::Deleted)\n    }\n\n    #[oai(\n        path = \"/profile/credentials/otp\",\n        method = \"post\",\n        operation_id = \"add_my_otp\",\n        transform = \"parameters_based_auth\"\n    )]\n    async fn api_create_otp(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<NewOtpCredential>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<CreateOtpCredentialResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(CreateOtpCredentialResponse::Unauthorized);\n        };\n\n        let mut user: User = user_model.clone().try_into()?;\n\n        let object = entities::OtpCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(user_model.id),\n            secret_key: Set(body.secret_key.clone()),\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        let details = user_model.load_details(&db).await?;\n        user.credential_policy = Some(\n            user.credential_policy\n                .unwrap_or_default()\n                .upgrade_to_otp(details.credentials.as_slice()),\n        );\n\n        entities::User::ActiveModel::try_from(user)?\n            .update(&*db)\n            .await?;\n\n        Ok(CreateOtpCredentialResponse::Created(Json(object.into())))\n    }\n\n    #[oai(\n        path = \"/profile/credentials/otp/:id\",\n        method = \"delete\",\n        operation_id = \"delete_my_otp\",\n        transform = \"parameters_based_auth\"\n    )]\n    async fn api_delete_otp(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<DeleteCredentialResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(DeleteCredentialResponse::Unauthorized);\n        };\n\n        let Some(model) = user_model\n            .find_related(entities::OtpCredential::Entity)\n            .filter(entities::OtpCredential::Column::Id.eq(id.0))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(DeleteCredentialResponse::NotFound);\n        };\n\n        model.delete(&*db).await?;\n        Ok(DeleteCredentialResponse::Deleted)\n    }\n\n    #[oai(\n        path = \"/profile/credentials/certificates\",\n        method = \"post\",\n        operation_id = \"issue_my_certificate\",\n        transform = \"parameters_based_auth\"\n    )]\n    async fn api_issue_certificate(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        body: Json<IssueCertificateCredentialRequest>,\n    ) -> Result<IssueCertificateCredentialResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(IssueCertificateCredentialResponse::Unauthorized);\n        };\n\n        // Fetch CA params\n        let params = Parameters::Entity::get(&db).await?;\n        let ca =\n            warpgate_ca::deserialize_ca(&params.ca_certificate_pem, &params.ca_private_key_pem)?;\n        let public_key_pem = body.public_key_pem.trim();\n        let client_cert = warpgate_ca::issue_client_certificate(\n            &ca,\n            &user_model.username,\n            public_key_pem,\n            user_model.id,\n        )?;\n        let client_cert_pem = warpgate_ca::certificate_to_pem(&client_cert)?;\n\n        let object = CertificateCredential::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            user_id: Set(user_model.id),\n            date_added: Set(Some(Utc::now())),\n            last_used: Set(None),\n            label: Set(body.label.clone()),\n            certificate_pem: Set(client_cert_pem.clone()),\n        }\n        .insert(&*db)\n        .await\n        .map_err(WarpgateError::from)?;\n\n        Ok(IssueCertificateCredentialResponse::Issued(Json(\n            IssuedCertificateCredential {\n                credential: object.clone().into(),\n                certificate_pem: client_cert_pem,\n            },\n        )))\n    }\n\n    #[oai(\n        path = \"/profile/credentials/certificates/:id\",\n        method = \"delete\",\n        operation_id = \"revoke_my_certificate\",\n        transform = \"parameters_based_auth\"\n    )]\n    async fn api_revoke_certificate(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        id: Path<Uuid>,\n    ) -> Result<DeleteCertificateCredentialResponse, WarpgateError> {\n        let auth = &ctx.auth;\n        let db = ctx.services.db.lock().await;\n\n        let Some(user_model) = get_user(auth, &db).await? else {\n            return Ok(DeleteCertificateCredentialResponse::Unauthorized);\n        };\n\n        let Some(model) = user_model\n            .find_related(entities::CertificateCredential::Entity)\n            .filter(entities::CertificateCredential::Column::Id.eq(id.0))\n            .one(&*db)\n            .await?\n        else {\n            return Ok(DeleteCertificateCredentialResponse::NotFound);\n        };\n\n        // Add to revocation list\n        let cert = warpgate_ca::deserialize_certificate(&model.certificate_pem)?;\n        entities::CertificateRevocation::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            date_added: Set(Utc::now()),\n            serial_number_base64: Set(warpgate_ca::serialize_certificate_serial(&cert)),\n        }\n        .insert(&*db)\n        .await?;\n\n        model.delete(&*db).await?;\n        Ok(DeleteCertificateCredentialResponse::Ok)\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/api/info.rs",
    "content": "use anyhow::Context;\nuse poem::session::Session;\nuse poem::web::Data;\nuse poem::Request;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter};\nuse serde::Serialize;\nuse warpgate_common::version::warpgate_version;\nuse warpgate_common_http::auth::UnauthenticatedRequestContext;\nuse warpgate_common_http::{AuthenticatedRequestContext, SessionAuthorization};\nuse warpgate_core::ConfigProvider;\nuse warpgate_db_entities::{AdminRole, LdapServer, Parameters, User};\n\nuse crate::common::{is_user_admin, SessionExt};\n\npub struct Api;\n\n#[derive(Serialize, Object)]\npub struct PortsInfo {\n    ssh: Option<u16>,\n    http: Option<u16>,\n    mysql: Option<u16>,\n    postgres: Option<u16>,\n    kubernetes: Option<u16>,\n}\n\n#[derive(Serialize, Object, Debug)]\npub struct SetupState {\n    has_targets: bool,\n    has_users: bool,\n}\n\nimpl SetupState {\n    pub fn completed(&self) -> bool {\n        self.has_targets && self.has_users\n    }\n}\n\n#[derive(Serialize, Object, Default)]\npub struct AdminPermissions {\n    targets_create: bool,\n    targets_edit: bool,\n    targets_delete: bool,\n\n    users_create: bool,\n    users_edit: bool,\n    users_delete: bool,\n\n    access_roles_create: bool,\n    access_roles_edit: bool,\n    access_roles_delete: bool,\n    access_roles_assign: bool,\n\n    sessions_view: bool,\n    sessions_terminate: bool,\n\n    recordings_view: bool,\n\n    tickets_create: bool,\n    tickets_delete: bool,\n\n    config_edit: bool,\n    admin_roles_manage: bool,\n}\n\n#[derive(Serialize, Object)]\npub struct Info {\n    version: Option<String>,\n    username: Option<String>,\n    selected_target: Option<String>,\n    external_host: Option<String>,\n    ports: PortsInfo,\n    minimize_password_login: bool,\n    authorized_via_ticket: bool,\n    authorized_via_sso_with_single_logout: bool,\n    own_credential_management_allowed: bool,\n    has_ldap: bool,\n    setup_state: Option<SetupState>,\n    admin_permissions: Option<AdminPermissions>,\n}\n\n#[derive(ApiResponse)]\nenum InstanceInfoResponse {\n    #[oai(status = 200)]\n    Ok(Json<Info>),\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(path = \"/info\", method = \"get\", operation_id = \"get_info\")]\n    async fn api_get_info(\n        &self,\n        req: &Request,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n        auth_ctx: Option<Data<&AuthenticatedRequestContext>>,\n    ) -> poem::Result<InstanceInfoResponse> {\n        let config = ctx.services.config.lock().await;\n        let external_host = config\n            .construct_external_url(Some(req), None)\n            .ok()\n            .as_ref()\n            .and_then(|x| x.host())\n            .map(|x| x.to_string());\n\n        let parameters = {\n            Parameters::Entity::get(&*ctx.services.db.lock().await)\n                .await\n                .context(\"loading parameters\")?\n        };\n\n        let setup_state = {\n            let (users, targets) = {\n                let mut p = ctx.services.config_provider.lock().await;\n                let users = p.list_users().await?;\n                let targets = p.list_targets().await?;\n                (users, targets)\n            };\n            let user_is_admin = if let Some(ctx) = &auth_ctx {\n                is_user_admin(ctx).await?\n            } else {\n                false\n            };\n            if user_is_admin {\n                let state = SetupState {\n                    has_targets: targets.len() > 1,\n                    has_users: users.len() > 1,\n                };\n                if !state.completed() {\n                    Some(state)\n                } else {\n                    None\n                }\n            } else {\n                None\n            }\n        };\n\n        let has_ldap = LdapServer::Entity::find()\n            .one(&*ctx.services.db.lock().await)\n            .await\n            .context(\"loading LDAP servers\")?\n            .is_some();\n\n        // compute admin permissions (only if authenticated)\n        let admin_permissions = if let Some(ctx) = &auth_ctx {\n            if let Some(username) = ctx.auth.username() {\n                let db = ctx.services.db.lock().await;\n                let perms = {\n                    let mut combined = AdminPermissions::default();\n                    if let Some(user) = User::Entity::find()\n                        .filter(User::Column::Username.eq(username))\n                        .one(&*db)\n                        .await\n                        .context(\"loading user\")?\n                    {\n                        let roles: Vec<AdminRole::Model> = user\n                            .find_related(AdminRole::Entity)\n                            .all(&*db)\n                            .await\n                            .context(\"loading roles\")?;\n                        for r in roles {\n                            combined.targets_create |= r.targets_create;\n                            combined.targets_edit |= r.targets_edit;\n                            combined.targets_delete |= r.targets_delete;\n                            combined.users_create |= r.users_create;\n                            combined.users_edit |= r.users_edit;\n                            combined.users_delete |= r.users_delete;\n                            combined.access_roles_create |= r.access_roles_create;\n                            combined.access_roles_edit |= r.access_roles_edit;\n                            combined.access_roles_delete |= r.access_roles_delete;\n                            combined.access_roles_assign |= r.access_roles_assign;\n                            combined.sessions_view |= r.sessions_view;\n                            combined.sessions_terminate |= r.sessions_terminate;\n                            combined.recordings_view |= r.recordings_view;\n                            combined.config_edit |= r.config_edit;\n                            combined.admin_roles_manage |= r.admin_roles_manage;\n                        }\n                    }\n                    combined\n                };\n                Some(perms)\n            } else {\n                None\n            }\n        } else {\n            None\n        };\n\n        Ok(InstanceInfoResponse::Ok(Json(Info {\n            version: auth_ctx.is_some().then(|| warpgate_version().to_string()),\n            username: session.get_username(),\n            selected_target: session.get_target_name(),\n            external_host,\n            minimize_password_login: parameters.minimize_password_login,\n            authorized_via_ticket: matches!(\n                session.get_auth(),\n                Some(SessionAuthorization::Ticket { .. })\n            ),\n            authorized_via_sso_with_single_logout: session\n                .get_sso_login_state()\n                .is_some_and(|state| state.supports_single_logout),\n            ports: if auth_ctx.is_some() {\n                PortsInfo {\n                    ssh: if config.store.ssh.enable {\n                        Some(config.store.ssh.external_port())\n                    } else {\n                        None\n                    },\n                    http: Some(config.store.http.external_port()),\n                    mysql: if config.store.mysql.enable {\n                        Some(config.store.mysql.external_port())\n                    } else {\n                        None\n                    },\n                    postgres: if config.store.postgres.enable {\n                        Some(config.store.postgres.external_port())\n                    } else {\n                        None\n                    },\n                    kubernetes: if config.store.kubernetes.enable {\n                        Some(config.store.kubernetes.external_port())\n                    } else {\n                        None\n                    },\n                }\n            } else {\n                PortsInfo {\n                    ssh: None,\n                    http: None,\n                    mysql: None,\n                    postgres: None,\n                    kubernetes: None,\n                }\n            },\n            own_credential_management_allowed: parameters.allow_own_credential_management,\n            setup_state,\n            has_ldap: auth_ctx.is_some() && has_ldap,\n            admin_permissions,\n        })))\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/api/mod.rs",
    "content": "use poem_openapi::OpenApi;\n\nmod api_tokens;\npub mod auth;\nmod common;\nmod credentials;\npub mod info;\npub mod sso_provider_detail;\npub mod sso_provider_list;\npub mod targets_list;\n\npub use warpgate_common::api::AnySecurityScheme;\n\npub fn get() -> impl OpenApi {\n    (\n        auth::Api,\n        info::Api,\n        targets_list::Api,\n        sso_provider_list::Api,\n        sso_provider_detail::Api,\n        credentials::Api,\n        api_tokens::Api,\n    )\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/api/sso_provider_detail.rs",
    "content": "use poem::session::Session;\nuse poem::web::Data;\nuse poem::Request;\nuse poem_openapi::param::{Path, Query};\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse serde::{Deserialize, Serialize};\nuse tracing::*;\nuse warpgate_common::WarpgateError;\nuse warpgate_common_http::auth::UnauthenticatedRequestContext;\nuse warpgate_sso::{SsoClient, SsoLoginRequest};\n\npub struct Api;\n\n#[derive(Object)]\nstruct StartSsoResponseParams {\n    url: String,\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(ApiResponse)]\nenum StartSsoResponse {\n    #[oai(status = 200)]\n    Ok(Json<StartSsoResponseParams>),\n    #[oai(status = 404)]\n    NotFound,\n}\n\npub static SSO_CONTEXT_SESSION_KEY: &str = \"sso_request\";\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct SsoContext {\n    pub provider: String,\n    pub request: SsoLoginRequest,\n    pub next_url: Option<String>,\n    pub supports_single_logout: bool,\n    pub return_host: Option<String>,\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/sso/providers/:name/start\",\n        method = \"get\",\n        operation_id = \"start_sso\"\n    )]\n    async fn api_start_sso(\n        &self,\n        req: &Request,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n        name: Path<String>,\n        next: Query<Option<String>>,\n    ) -> Result<StartSsoResponse, WarpgateError> {\n        let config = ctx.services.config.lock().await;\n\n        let name = name.0;\n\n        let Some(provider_config) = config.store.sso_providers.iter().find(|p| p.name == *name)\n        else {\n            return Ok(StartSsoResponse::NotFound);\n        };\n        let mut return_url = config.construct_external_url(\n            Some(req),\n            provider_config.return_domain_whitelist.as_deref(),\n        )?;\n        info!(\"{:?}\", provider_config);\n        return_url.set_path(&format!(\n            \"{}warpgate/api/sso/return\",\n            provider_config.return_url_prefix\n        ));\n        debug!(\"Return URL: {return_url}\");\n\n        let client = SsoClient::new(provider_config.provider.clone())?;\n\n        let sso_req = client.start_login(return_url.to_string()).await?;\n        let return_host = req.header(\"host\").map(|h| h.to_string());\n\n        let url = sso_req.auth_url().to_string();\n        session.set(\n            SSO_CONTEXT_SESSION_KEY,\n            SsoContext {\n                provider: name,\n                request: sso_req,\n                next_url: next.0.clone(),\n                supports_single_logout: client.supports_single_logout().await?,\n                return_host,\n            },\n        );\n\n        Ok(StartSsoResponse::Ok(Json(StartSsoResponseParams { url })))\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/api/sso_provider_list.rs",
    "content": "use std::ops::DerefMut;\nuse std::sync::Arc;\n\nuse poem::session::Session;\nuse poem::web::{Data, Form};\nuse poem::Request;\nuse poem_openapi::param::Query;\nuse poem_openapi::payload::{Html, Json, Response};\nuse poem_openapi::{ApiResponse, Enum, Object, OpenApi};\nuse serde::Deserialize;\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse warpgate_common::auth::{AuthCredential, AuthResult};\nuse warpgate_common::WarpgateError;\nuse warpgate_common_http::auth::UnauthenticatedRequestContext;\nuse warpgate_core::ConfigProvider;\nuse warpgate_sso::{SsoClient, SsoInternalProviderConfig};\n\nuse super::sso_provider_detail::{SsoContext, SSO_CONTEXT_SESSION_KEY};\nuse crate::api::common::logout;\nuse crate::common::{authorize_session, get_auth_state_for_request, SessionExt};\nuse crate::session::SessionStore;\nuse crate::SsoLoginState;\n\npub struct Api;\n\n#[derive(Enum)]\npub enum SsoProviderKind {\n    Google,\n    Apple,\n    Azure,\n    Custom,\n}\n\n#[derive(Object)]\npub struct SsoProviderDescription {\n    pub name: String,\n    pub label: String,\n    pub kind: SsoProviderKind,\n}\n\n#[derive(ApiResponse)]\nenum GetSsoProvidersResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<SsoProviderDescription>>),\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(ApiResponse)]\nenum ReturnToSsoResponse {\n    #[oai(status = 307)]\n    Ok,\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(ApiResponse)]\nenum ReturnToSsoPostResponse {\n    #[oai(status = 200)]\n    Redirect(Html<String>),\n}\n\n#[derive(Deserialize)]\npub struct ReturnToSsoFormData {\n    pub code: Option<String>,\n}\n\n#[derive(Object)]\nstruct StartSloResponseParams {\n    url: String,\n}\n\n#[allow(clippy::large_enum_variant)]\n#[derive(ApiResponse)]\nenum StartSloResponse {\n    #[oai(status = 200)]\n    Ok(Json<StartSloResponseParams>),\n    #[oai(status = 400)]\n    NotInSsoSession,\n    #[oai(status = 404)]\n    NotFound,\n}\n\nfn make_redirect_url(err: &str) -> String {\n    error!(\"SSO error: {err}\");\n    format!(\"/@warpgate?login_error={err}\")\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/sso/providers\",\n        method = \"get\",\n        operation_id = \"get_sso_providers\"\n    )]\n    async fn api_get_all_sso_providers(\n        &self,\n        ctx: Data<&UnauthenticatedRequestContext>,\n    ) -> Result<GetSsoProvidersResponse, WarpgateError> {\n        let mut providers = ctx.services.config.lock().await.store.sso_providers.clone();\n        providers.sort_by(|a, b| a.label().cmp(b.label()));\n        Ok(GetSsoProvidersResponse::Ok(Json(\n            providers\n                .into_iter()\n                .map(|p| SsoProviderDescription {\n                    name: p.name.clone(),\n                    label: p.label().to_string(),\n                    kind: match p.provider {\n                        SsoInternalProviderConfig::Google { .. } => SsoProviderKind::Google,\n                        SsoInternalProviderConfig::Apple { .. } => SsoProviderKind::Apple,\n                        SsoInternalProviderConfig::Azure { .. } => SsoProviderKind::Azure,\n                        SsoInternalProviderConfig::Custom { .. } => SsoProviderKind::Custom,\n                    },\n                })\n                .collect(),\n        )))\n    }\n\n    #[oai(path = \"/sso/return\", method = \"get\", operation_id = \"return_to_sso\")]\n    async fn api_return_to_sso_get(\n        &self,\n        req: &Request,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n        code: Query<Option<String>>,\n    ) -> Result<Response<ReturnToSsoResponse>, WarpgateError> {\n        let url = self\n            .api_return_to_sso_get_common(req, session, ctx, &code)\n            .await?\n            .unwrap_or_else(|x| make_redirect_url(&x));\n\n        Ok(Response::new(ReturnToSsoResponse::Ok).header(\"Location\", url))\n    }\n\n    #[oai(\n        path = \"/sso/return\",\n        method = \"post\",\n        operation_id = \"return_to_sso_with_form_data\"\n    )]\n    async fn api_return_to_sso_post(\n        &self,\n        req: &Request,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n        data: Form<ReturnToSsoFormData>,\n    ) -> Result<ReturnToSsoPostResponse, WarpgateError> {\n        let url = self\n            .api_return_to_sso_get_common(req, session, ctx, &data.code)\n            .await?\n            .unwrap_or_else(|x| make_redirect_url(&x));\n        let serialized_url = serde_json::to_string(&url)?;\n        Ok(ReturnToSsoPostResponse::Redirect(\n            poem_openapi::payload::Html(format!(\n                \"<!doctype html>\\n\n                <html>\n                    <script>\n                        location.href = {serialized_url};\n                    </script>\n                    <body>\n                        Redirecting to <a href='{url}'>{url}</a>...\n                    </body>\n                </html>\n            \"\n            )),\n        ))\n    }\n\n    async fn api_return_to_sso_get_common(\n        &self,\n        req: &Request,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n        code: &Option<String>,\n    ) -> Result<Result<String, String>, WarpgateError> {\n        // pull services locally for convenience\n        let services = &ctx.services;\n        let Some(context) = session.get::<SsoContext>(SSO_CONTEXT_SESSION_KEY) else {\n            return Ok(Err(\"Not in an active SSO process\".to_string()));\n        };\n\n        let Some(ref code) = *code else {\n            return Ok(Err(\n                \"No authorization code in the return URL request\".to_string()\n            ));\n        };\n\n        let response = context\n            .request\n            .verify_code((*code).clone())\n            .await\n            .inspect_err(|e| {\n                warn!(\"Failed to verify SSO code: {e:?}\");\n            })?;\n\n        if !response.email_verified.unwrap_or(true) {\n            return Ok(Err(\"The SSO account's e-mail is not verified\".to_string()));\n        }\n\n        let Some(email) = response.email else {\n            return Ok(Err(\"No e-mail information in the SSO response\".to_string()));\n        };\n\n        info!(\"SSO login as {email}\");\n\n        let providers_config = ctx.services.config.lock().await.store.sso_providers.clone();\n        let mut iter = providers_config.iter();\n        let Some(provider_config) = iter.find(|x| x.name == context.provider) else {\n            return Ok(Err(format!(\"No provider matching {}\", context.provider)));\n        };\n\n        let cred = AuthCredential::Sso {\n            provider: context.provider.clone(),\n            email: email.clone(),\n        };\n\n        let username = services\n            .config_provider\n            .lock()\n            .await\n            .username_for_sso_credential(\n                &cred,\n                response.preferred_username,\n                provider_config.clone(),\n            )\n            .await?;\n        let Some(username) = username else {\n            return Ok(Err(format!(\"No user matching {email}\")));\n        };\n\n        let mut auth_state_store = services.auth_state_store.lock().await;\n        let state_arc =\n            get_auth_state_for_request(&username, session, &mut auth_state_store).await?;\n\n        let mut state = state_arc.lock().await;\n        let mut cp = services.config_provider.lock().await;\n\n        if state.user_info().username != username {\n            return Ok(Err(format!(\n                \"Incorrect account for SSO authentication ({username})\"\n            )));\n        }\n\n        if cp.validate_credential(&username, &cred).await? {\n            state.add_valid_credential(cred);\n        } else {\n            return Ok(Err(format!(\n                \"Failed to validate SSO credential for {username}\"\n            )));\n        }\n\n        if let AuthResult::Accepted { user_info } = state.verify() {\n            auth_state_store.complete(state.id()).await;\n            authorize_session(req, &ctx, user_info).await?;\n            session.set_sso_login_state(SsoLoginState {\n                provider: context.provider,\n                token: response.id_token,\n                supports_single_logout: context.supports_single_logout,\n            });\n        }\n\n        let mappings = provider_config.provider.role_mappings();\n        if let Some(remote_groups) = response.access_roles {\n            // If mappings is not set, all groups are subject to sync\n            // and names won't be remapped\n            let managed_role_names = mappings\n                .as_ref()\n                .map(|m| m.iter().flat_map(|(_, v)| v.roles()).collect::<Vec<_>>());\n\n            let mut active_role_names: Vec<String> = if let Some(ref mappings) = mappings {\n                // Apply wildcard \"*\" mapping if user has any groups\n                let mut roles: Vec<String> = if !remote_groups.is_empty() {\n                    mappings.get(\"*\").map(|v| v.roles()).unwrap_or_default()\n                } else {\n                    Vec::new()\n                };\n\n                // Apply specific group mappings\n                for group in &remote_groups {\n                    if let Some(mapping) = mappings.get(group) {\n                        roles.extend(mapping.roles());\n                    }\n                }\n\n                roles\n            } else {\n                // No mappings configured, pass through group names as-is\n                remote_groups\n            };\n\n            active_role_names.sort();\n            active_role_names.dedup();\n\n            debug!(\"SSO role mappings for {username}: active={active_role_names:?}, managed={managed_role_names:?}\");\n            cp.apply_sso_role_mappings(&username, managed_role_names, active_role_names)\n                .await?;\n        }\n\n        // import admin roles from claim if present\n        if let Some(remote_admins) = response.admin_roles {\n            let admin_map = provider_config.provider.admin_role_mappings();\n\n            // compute managed list from mapping values (or all role names if no mapping provided)\n            let managed_admin_names: Option<Vec<String>> = admin_map\n                .as_ref()\n                .map(|m| m.values().flat_map(|v| v.roles()).collect());\n\n            let active_admin_names: Vec<_> = if let Some(ref mappings) = admin_map {\n                remote_admins\n                    .iter()\n                    .flat_map(|r| mappings.get(r).map(|v| v.roles()).into_iter().flatten())\n                    .collect()\n            } else {\n                remote_admins.clone()\n            };\n\n            debug!(\"SSO admin role mappings for {username}: active={active_admin_names:?}, managed={managed_admin_names:?}\");\n            cp.apply_sso_admin_role_mappings(&username, managed_admin_names, active_admin_names)\n                .await?;\n        }\n\n        let mut next_url = context\n            .next_url\n            .as_deref()\n            .unwrap_or(\"/@warpgate#/login\")\n            .to_owned();\n\n        if let Some(ref host) = context.return_host {\n            if next_url.starts_with('/') {\n                next_url = format!(\"https://{}{}\", host, next_url);\n            }\n        }\n\n        Ok(Ok(next_url))\n    }\n\n    #[oai(\n        path = \"/sso/logout\",\n        method = \"get\",\n        operation_id = \"initiate_sso_logout\"\n    )]\n    async fn api_start_slo(\n        &self,\n        req: &Request,\n        session: &Session,\n        ctx: Data<&UnauthenticatedRequestContext>,\n        session_middleware: Data<&Arc<Mutex<SessionStore>>>,\n    ) -> Result<StartSloResponse, WarpgateError> {\n        let Some(state) = session.get_sso_login_state() else {\n            return Ok(StartSloResponse::NotInSsoSession);\n        };\n\n        let config = ctx.services.config.lock().await;\n\n        let return_url = config.construct_external_url(Some(req), None)?;\n        debug!(\"Return URL: {}\", &return_url);\n\n        let Some(provider_config) = config\n            .store\n            .sso_providers\n            .iter()\n            .find(|p| p.name == state.provider)\n        else {\n            return Ok(StartSloResponse::NotFound);\n        };\n\n        let client = SsoClient::new(provider_config.provider.clone())?;\n        let logout_url = client.logout(state.token, return_url).await?;\n\n        logout(session, session_middleware.lock().await.deref_mut());\n\n        Ok(StartSloResponse::Ok(Json(StartSloResponseParams {\n            url: logout_url.to_string(),\n        })))\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/api/targets_list.rs",
    "content": "use std::collections::HashMap;\n\nuse futures::{stream, StreamExt};\nuse poem::web::Data;\nuse poem_openapi::param::Query;\nuse poem_openapi::payload::Json;\nuse poem_openapi::{ApiResponse, Object, OpenApi};\nuse sea_orm::EntityTrait;\nuse serde::Serialize;\nuse warpgate_common::{Target as TargetConfig, TargetOptions, WarpgateError};\nuse warpgate_common_http::{\n    AuthenticatedRequestContext, RequestAuthorization, SessionAuthorization,\n};\nuse warpgate_core::ConfigProvider;\nuse warpgate_db_entities::TargetGroup::BootstrapThemeColor;\nuse warpgate_db_entities::{Target, TargetGroup};\n\nuse crate::api::AnySecurityScheme;\nuse crate::common::endpoint_auth;\n\npub struct Api;\n\n#[derive(Debug, Serialize, Clone, Object)]\npub struct GroupInfo {\n    pub id: uuid::Uuid,\n    pub name: String,\n    pub color: Option<BootstrapThemeColor>,\n}\n\n#[derive(Debug, Serialize, Clone, Object)]\npub struct TargetSnapshot {\n    pub name: String,\n    pub description: String,\n    pub kind: Target::TargetKind,\n    pub external_host: Option<String>,\n    pub group: Option<GroupInfo>,\n    pub default_database_name: Option<String>,\n}\n\n#[derive(ApiResponse)]\nenum GetTargetsResponse {\n    #[oai(status = 200)]\n    Ok(Json<Vec<TargetSnapshot>>),\n}\n\n#[OpenApi]\nimpl Api {\n    #[oai(\n        path = \"/targets\",\n        method = \"get\",\n        operation_id = \"get_targets\",\n        transform = \"endpoint_auth\"\n    )]\n    async fn api_get_all_targets(\n        &self,\n        ctx: Data<&AuthenticatedRequestContext>,\n        search: Query<Option<String>>,\n        _sec_scheme: AnySecurityScheme,\n    ) -> Result<GetTargetsResponse, WarpgateError> {\n        // Fetch target groups for group information\n        let services = &ctx.services;\n        let groups: Vec<TargetGroup::Model> = {\n            let db = services.db.lock().await;\n            TargetGroup::Entity::find().all(&*db).await\n        }?;\n\n        let group_map: HashMap<uuid::Uuid, &TargetGroup::Model> =\n            groups.iter().map(|g| (g.id, g)).collect();\n\n        let mut targets: Vec<TargetConfig> = {\n            let mut config_provider = services.config_provider.lock().await;\n            config_provider.list_targets().await?\n        };\n\n        if let Some(ref search) = *search {\n            let search = search.to_lowercase();\n            targets.retain(|t| {\n                let group = t.group_id.and_then(|group_id| group_map.get(&group_id));\n                t.name.to_lowercase().contains(&search)\n                    || group\n                        .map(|g| g.name.to_lowercase().contains(&search))\n                        .unwrap_or(false)\n            })\n        }\n\n        let auth_clone = ctx.auth.clone();\n        let targets: Vec<_> = stream::iter(targets)\n            .filter(|t| {\n                let services = services.clone();\n                let auth = auth_clone.clone();\n                let name = t.name.clone();\n                async move {\n                    match auth {\n                        RequestAuthorization::Session(SessionAuthorization::Ticket {\n                            target_name,\n                            ..\n                        }) => target_name == name,\n                        _ => {\n                            let mut config_provider = services.config_provider.lock().await;\n                            let Some(username) = auth.username() else {\n                                return false;\n                            };\n                            matches!(\n                                config_provider.authorize_target(username, &name).await,\n                                Ok(true)\n                            )\n                        }\n                    }\n                }\n            })\n            .collect::<Vec<_>>()\n            .await;\n\n        let result: Vec<TargetSnapshot> = targets\n            .into_iter()\n            .map(|t| {\n                let group = t.group_id.and_then(|group_id| {\n                    group_map.get(&group_id).map(|group| GroupInfo {\n                        id: group.id,\n                        name: group.name.clone(),\n                        color: group.color.clone(),\n                    })\n                });\n\n                TargetSnapshot {\n                    name: t.name.clone(),\n                    description: t.description.clone(),\n                    kind: (&t.options).into(),\n                    external_host: match t.options {\n                        TargetOptions::Http(ref opt) => opt.external_host.clone(),\n                        _ => None,\n                    },\n                    default_database_name: match t.options {\n                        TargetOptions::Postgres(ref opt) => opt.default_database_name.clone(),\n                        TargetOptions::MySql(ref opt) => opt.default_database_name.clone(),\n                        _ => None,\n                    },\n                    group,\n                }\n            })\n            .collect();\n\n        Ok(GetTargetsResponse::Ok(Json(result)))\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/catchall.rs",
    "content": "use std::sync::Arc;\n\nuse http::header::HOST;\nuse poem::session::Session;\nuse poem::web::websocket::WebSocket;\nuse poem::web::{Data, FromRequest, Redirect};\nuse poem::{handler, Body, IntoResponse, Request, Response};\nuse serde::Deserialize;\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse warpgate_common::{Target, TargetHTTPOptions, TargetOptions};\nuse warpgate_common_http::{\n    AuthenticatedRequestContext, RequestAuthorization, SessionAuthorization,\n};\nuse warpgate_core::{ConfigProvider, WarpgateServerHandle};\n\nuse crate::common::SessionExt;\nuse crate::proxy::{proxy_normal_request, proxy_websocket_request};\n\n#[derive(Deserialize)]\nstruct QueryParams {\n    #[serde(rename = \"warpgate-target\")]\n    warpgate_target: Option<String>,\n}\n\npub fn target_select_redirect() -> Response {\n    Redirect::temporary(\"/@warpgate\").into_response()\n}\n\n#[handler]\npub async fn catchall_endpoint(\n    req: &Request,\n    ws: Option<WebSocket>,\n    session: &Session,\n    body: Body,\n    ctx: Data<&AuthenticatedRequestContext>,\n    server_handle: Option<Data<&Arc<Mutex<WarpgateServerHandle>>>>,\n) -> poem::Result<Response> {\n    let target_and_options = get_target_for_request(req, &ctx).await?;\n    let Some((target, options)) = target_and_options else {\n        return Ok(target_select_redirect());\n    };\n\n    session.set_target_name(target.name.clone());\n\n    if let Some(server_handle) = server_handle {\n        server_handle.lock().await.set_target(&target).await?;\n    }\n\n    let span = info_span!(\"\", target=%target.name);\n\n    Ok(match ws {\n        Some(ws) => proxy_websocket_request(req, ws, &options)\n            .instrument(span)\n            .await?\n            .into_response(),\n        None => proxy_normal_request(req, *ctx, body, &options)\n            .instrument(span)\n            .await?\n            .into_response(),\n    })\n}\n\nasync fn get_target_for_request(\n    req: &Request,\n    ctx: &AuthenticatedRequestContext,\n) -> poem::Result<Option<(Target, TargetHTTPOptions)>> {\n    let session = <&Session>::from_request_without_body(req).await?;\n    let params: QueryParams = req.params()?;\n\n    let selected_target_name;\n    let need_role_auth;\n\n    let request_host = req\n        .header(HOST)\n        .map(|h| h.split(':').next().unwrap_or(h).to_string())\n        .or_else(|| req.original_uri().host().map(|x| x.to_string()));\n\n    let host_based_target_name = if let Some(host) = request_host {\n        let found = ctx\n            .services\n            .config_provider\n            .lock()\n            .await\n            .list_targets()\n            .await?\n            .iter()\n            .filter_map(|t| match t.options {\n                TargetOptions::Http(ref options) => Some((t, options)),\n                _ => None,\n            })\n            .find(|(_, o)| o.external_host.as_deref() == Some(&host))\n            .map(|(t, _)| t.name.clone());\n\n        if found.is_some() {\n            debug!(\n                \"Domain rebinding detected: host={} -> target={:?}\",\n                host, found\n            );\n        }\n        found\n    } else {\n        None\n    };\n\n    let username = match &ctx.auth {\n        RequestAuthorization::Session(SessionAuthorization::Ticket {\n            target_name,\n            username,\n        }) => {\n            selected_target_name = Some(target_name.clone());\n            need_role_auth = false;\n            username\n        }\n        RequestAuthorization::Session(SessionAuthorization::User(username)) => {\n            need_role_auth = true;\n\n            selected_target_name = if let Some(ref rebound_target) = host_based_target_name {\n                Some(rebound_target.clone())\n            } else if let Some(warpgate_target) = params.warpgate_target {\n                Some(warpgate_target)\n            } else {\n                session.get_target_name()\n            };\n            username\n        }\n        RequestAuthorization::UserToken { .. } | RequestAuthorization::AdminToken => {\n            return Ok(None)\n        }\n    };\n\n    let domain_rebinding_configured = host_based_target_name.is_some();\n    let final_target_name = selected_target_name.or(host_based_target_name);\n\n    if let Some(target_name) = final_target_name {\n        let target = {\n            ctx.services\n                .config_provider\n                .lock()\n                .await\n                .list_targets()\n                .await?\n                .iter()\n                .filter(|t| t.name == target_name)\n                .filter_map(|t| match t.options {\n                    TargetOptions::Http(ref options) => Some((t, options)),\n                    _ => None,\n                })\n                .next()\n                .map(|(t, o)| (t.clone(), o.clone()))\n        };\n\n        if let Some(target) = target {\n            if need_role_auth\n                && !ctx\n                    .services\n                    .config_provider\n                    .lock()\n                    .await\n                    .authorize_target(username, &target.0.name)\n                    .await?\n            {\n                return Ok(None);\n            }\n\n            return Ok(Some(target));\n        }\n    }\n\n    if domain_rebinding_configured {\n        debug!(\n            \"Domain rebinding was configured for this host but target was not selected. This may indicate the target doesn't exist or user is not authorized.\"\n        );\n    }\n\n    Ok(None)\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/common.rs",
    "content": "use core::str;\nuse std::sync::Arc;\n\nuse anyhow::Context;\nuse http::header::HOST;\nuse http::{HeaderName, StatusCode};\nuse percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};\nuse poem::error::InternalServerError;\nuse poem::session::Session;\nuse poem::web::{Data, Redirect};\nuse poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, Response};\nuse sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};\nuse serde::{Deserialize, Serialize};\nuse tokio::sync::Mutex;\nuse uuid::Uuid;\nuse warpgate_common::auth::{AuthState, AuthStateUserInfo, CredentialKind};\nuse warpgate_common::{ProtocolName, WarpgateError};\nuse warpgate_common_http::auth::UnauthenticatedRequestContext;\nuse warpgate_common_http::{\n    AuthenticatedRequestContext, RequestAuthorization, SessionAuthorization,\n};\nuse warpgate_core::{AuthStateStore, ConfigProvider};\nuse warpgate_db_entities::{User, UserAdminRoleAssignment};\nuse warpgate_sso::CoreIdToken;\n\nuse crate::session::SessionStore;\n\npub const PROTOCOL_NAME: ProtocolName = \"HTTP\";\nstatic TARGET_SESSION_KEY: &str = \"target_name\";\nstatic AUTH_SESSION_KEY: &str = \"auth\";\nstatic AUTH_STATE_ID_SESSION_KEY: &str = \"auth_state_id\";\nstatic AUTH_SSO_LOGIN_STATE: &str = \"auth_sso_login_state\";\npub static SESSION_COOKIE_NAME: &str = \"warpgate-http-session\";\npub static X_WARPGATE_TOKEN: HeaderName = HeaderName::from_static(\"x-warpgate-token\");\n\n/// Check if a host is localhost or 127.x.x.x (for development/testing scenarios)\npub fn is_localhost_host(host: &str) -> bool {\n    host == \"localhost\" || host == \"127.0.0.1\" || host.starts_with(\"127.\")\n}\n\n#[derive(Serialize, Deserialize)]\npub struct SsoLoginState {\n    pub token: CoreIdToken,\n    pub provider: String,\n    pub supports_single_logout: bool,\n}\n\npub trait SessionExt {\n    fn get_target_name(&self) -> Option<String>;\n    fn set_target_name(&self, target_name: String);\n    fn get_username(&self) -> Option<String>;\n    fn get_auth(&self) -> Option<SessionAuthorization>;\n    fn set_auth(&self, auth: SessionAuthorization);\n    fn get_auth_state_id(&self) -> Option<AuthStateId>;\n    fn clear_auth_state(&self);\n\n    fn get_sso_login_state(&self) -> Option<SsoLoginState>;\n    fn set_sso_login_state(&self, token: SsoLoginState);\n}\n\nimpl SessionExt for Session {\n    fn get_target_name(&self) -> Option<String> {\n        self.get(TARGET_SESSION_KEY)\n    }\n\n    fn set_target_name(&self, target_name: String) {\n        self.set(TARGET_SESSION_KEY, target_name);\n    }\n\n    fn get_username(&self) -> Option<String> {\n        self.get_auth().map(|x| x.username().to_owned())\n    }\n\n    fn get_auth(&self) -> Option<SessionAuthorization> {\n        self.get(AUTH_SESSION_KEY)\n    }\n\n    fn set_auth(&self, auth: SessionAuthorization) {\n        self.set(AUTH_SESSION_KEY, auth);\n    }\n\n    fn get_auth_state_id(&self) -> Option<AuthStateId> {\n        self.get(AUTH_STATE_ID_SESSION_KEY)\n    }\n\n    fn clear_auth_state(&self) {\n        self.remove(AUTH_STATE_ID_SESSION_KEY)\n    }\n\n    fn get_sso_login_state(&self) -> Option<SsoLoginState> {\n        self.get::<String>(AUTH_SSO_LOGIN_STATE)\n            .and_then(|x| serde_json::from_str(&x).ok())\n    }\n\n    fn set_sso_login_state(&self, state: SsoLoginState) {\n        if let Ok(json) = serde_json::to_string(&state) {\n            self.set(AUTH_SSO_LOGIN_STATE, json)\n        }\n    }\n}\n\n#[derive(Clone, Serialize, Deserialize)]\npub struct AuthStateId(pub Uuid);\n\npub async fn is_user_admin(ctx: &AuthenticatedRequestContext) -> poem::Result<bool> {\n    // A user is considered an administrator if they have any admin role assigned.\n    let services = &ctx.services;\n\n    // Admin tokens bypass the database check and are always full administrators.\n    if let RequestAuthorization::AdminToken = ctx.auth {\n        return Ok(true);\n    }\n\n    let username = match &ctx.auth {\n        RequestAuthorization::Session(SessionAuthorization::User(username)) => username,\n        RequestAuthorization::Session(SessionAuthorization::Ticket { .. }) => return Ok(false),\n        RequestAuthorization::UserToken { username } => username,\n        RequestAuthorization::AdminToken => unreachable!(),\n    };\n\n    let db = services.db.lock().await;\n\n    let Some(user_model) = User::Entity::find()\n        .filter(User::Column::Username.eq(username))\n        .one(&*db)\n        .await\n        .map_err(InternalServerError)?\n    else {\n        return Ok(false);\n    };\n\n    let count: u64 = UserAdminRoleAssignment::Entity::find()\n        .filter(UserAdminRoleAssignment::Column::UserId.eq(user_model.id))\n        .count(&*db)\n        .await\n        .map_err(InternalServerError)?;\n\n    Ok(count > 0)\n}\n\npub async fn _inner_auth<E: Endpoint + 'static>(\n    ep: Arc<E>,\n    req: Request,\n) -> poem::Result<Option<E::Output>> {\n    let ctx = Option::<Data<&AuthenticatedRequestContext>>::from_request_without_body(&req).await?;\n    if ctx.is_none() {\n        return Ok(None);\n    }\n    return ep.call(req).await.map(Some);\n}\n\n// TODO unify both based on the accept header\npub fn endpoint_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint<Output = E::Output> {\n    e.around(|ep, req| async move {\n        _inner_auth(ep, req)\n            .await?\n            .ok_or_else(|| poem::Error::from_status(StatusCode::UNAUTHORIZED))\n    })\n}\n\npub fn page_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {\n    e.around(|ep, req| async move {\n        let err_resp = gateway_redirect(&req).into_response();\n        Ok(_inner_auth(ep, req)\n            .await?\n            .map(IntoResponse::into_response)\n            .unwrap_or(err_resp))\n    })\n}\n\npub fn gateway_redirect(req: &Request) -> Response {\n    let path = req\n        .original_uri()\n        .path_and_query()\n        .map(|p| p.to_string())\n        .unwrap_or_else(|| \"\".into());\n\n    let path = format!(\n        \"/@warpgate#/login?next={}\",\n        utf8_percent_encode(&path, NON_ALPHANUMERIC),\n    );\n\n    Redirect::temporary(path).into_response()\n}\n\npub async fn get_auth_state_for_request(\n    username: &str,\n    session: &Session,\n    store: &mut AuthStateStore,\n) -> Result<Arc<Mutex<AuthState>>, WarpgateError> {\n    if let Some(id) = session.get_auth_state_id() {\n        if !store.contains_key(&id.0) {\n            session.remove(AUTH_STATE_ID_SESSION_KEY)\n        }\n    }\n\n    if let Some(id) = session.get_auth_state_id() {\n        let state = store.get(&id.0).ok_or(WarpgateError::InconsistentState)?;\n\n        let existing_matched = state.lock().await.user_info().username == username;\n        if existing_matched {\n            return Ok(state);\n        }\n    }\n\n    let (id, state) = store\n        .create(\n            None,\n            username,\n            crate::common::PROTOCOL_NAME,\n            &[\n                CredentialKind::Password,\n                CredentialKind::Sso,\n                CredentialKind::Totp,\n            ],\n        )\n        .await?;\n    session.set(AUTH_STATE_ID_SESSION_KEY, AuthStateId(id));\n    Ok(state)\n}\n\npub async fn authorize_session(\n    req: &Request,\n    ctx: &UnauthenticatedRequestContext,\n    user_info: AuthStateUserInfo,\n) -> Result<(), WarpgateError> {\n    let session_middleware = Data::<&Arc<Mutex<SessionStore>>>::from_request_without_body(req)\n        .await\n        .context(\"SessionStore not in request\")?;\n    let session = <&Session>::from_request_without_body(req)\n        .await\n        .context(\"Session not in request\")?;\n\n    let server_handle = session_middleware\n        .lock()\n        .await\n        .create_handle_for(req, ctx)\n        .await\n        .context(\"create_handle_for\")?;\n    server_handle\n        .lock()\n        .await\n        .set_user_info(user_info.clone())\n        .await?;\n    session.set_auth(SessionAuthorization::User(user_info.username));\n\n    Ok(())\n}\n\npub async fn inject_request_authorization<E: Endpoint + 'static>(\n    ep: Arc<E>,\n    req: Request,\n) -> poem::Result<E::Output> {\n    let ctx = Data::<&UnauthenticatedRequestContext>::from_request_without_body(&req).await?;\n    let session = <&Session>::from_request_without_body(&req).await?;\n\n    let mut session_auth = session.get_auth();\n    if session_auth.is_some() {\n        let config = ctx.services.config.lock().await;\n        if let Ok(base_url) = config.construct_external_url(None, None) {\n            if let Some(base_host) = base_url.host_str() {\n                let request_host = req\n                    .header(HOST)\n                    .map(|h| h.split(':').next().unwrap_or(h).to_string())\n                    .or_else(|| req.original_uri().host().map(|x| x.to_string()));\n\n                if let Some(host) = request_host {\n                    // Validate request host matches base host or is a subdomain/localhost\n                    let is_localhost = is_localhost_host(&host);\n                    let is_authorized = host == base_host\n                        || host.ends_with(&format!(\".{}\", base_host))\n                        || (is_localhost && base_host != \"localhost\" && base_host != \"127.0.0.1\");\n\n                    if !is_authorized {\n                        tracing::warn!(\n                            \"Session cookie rejected: request host '{}' is not authorized (base host: '{}'). Clearing session.\",\n                            host,\n                            base_host\n                        );\n                        session.clear();\n                        session_auth = None;\n                    }\n                }\n            }\n        }\n    }\n\n    let auth = match session_auth {\n        Some(auth) => Some(RequestAuthorization::Session(auth)),\n        None => match req.headers().get(&X_WARPGATE_TOKEN) {\n            Some(token_from_header) => {\n                let token_from_header = token_from_header\n                    .to_str()\n                    .map_err(poem::error::BadRequest)?;\n                if Some(token_from_header) == ctx.services.admin_token.lock().await.as_deref() {\n                    Some(RequestAuthorization::AdminToken)\n                } else if let Some(user) = ctx\n                    .services\n                    .config_provider\n                    .lock()\n                    .await\n                    .validate_api_token(token_from_header)\n                    .await?\n                {\n                    Some(RequestAuthorization::UserToken {\n                        username: user.username,\n                    })\n                } else {\n                    None\n                }\n            }\n            None => None,\n        },\n    };\n\n    if let Some(auth) = auth {\n        // build context and attach it instead of raw authorization\n        let ctx = ctx.to_authenticated(auth);\n        Ok(ep.data(ctx).call(req).await?)\n    } else {\n        Ok(ep.call(req).await?)\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/error.rs",
    "content": "use http::StatusCode;\nuse poem::IntoResponse;\nuse tracing::error;\n\npub fn error_page(e: poem::Error) -> impl IntoResponse {\n    error!(\"{:?}\", e);\n    poem::web::Html(format!(\n        r#\"<!DOCTYPE html>\n        <style>\n            body {{\n                font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n            }}\n\n            img {{\n                width: 100px;\n            }}\n\n            main {{\n                width: 400px;\n                margin: 200px auto;\n            }}\n        </style>\n        <main>\n            <img src=\"/@warpgate/assets/brand.svg\" />\n            <h1>Request failed</h1>\n            <p>{e}</p>\n        </main>\n        \"#\n    )).with_status(StatusCode::BAD_GATEWAY)\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/lib.rs",
    "content": "pub mod api;\nmod catchall;\nmod common;\nmod error;\nmod middleware;\npub mod proxy;\nmod session;\nmod session_handle;\n\nuse std::fmt::Debug;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse anyhow::{Context, Result};\nuse common::inject_request_authorization;\npub use common::{SsoLoginState, PROTOCOL_NAME};\nuse http::HeaderValue;\nuse poem::endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint};\nuse poem::listener::{Listener, RustlsConfig};\nuse poem::middleware::SetHeader;\nuse poem::session::{CookieConfig, MemoryStorage, ServerSession};\nuse poem::web::Data;\nuse poem::{Endpoint, EndpointExt, FromRequest, IntoEndpoint, IntoResponse, Route, Server};\nuse poem_openapi::OpenApiService;\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse warpgate_admin::admin_api_app;\nuse warpgate_common::version::warpgate_version;\nuse warpgate_common::{GlobalParams, ListenEndpoint, WarpgateConfig};\nuse warpgate_common_http::auth::UnauthenticatedRequestContext;\nuse warpgate_common_http::logging::{\n    get_client_ip, log_request_error, log_request_result, span_for_request,\n};\nuse warpgate_core::{ProtocolServer, Services};\nuse warpgate_tls::{\n    IntoTlsCertificateRelativePaths, RustlsSetupError, TlsCertificateAndPrivateKey,\n    TlsCertificateBundle, TlsPrivateKey,\n};\nuse warpgate_web::Assets;\n\nuse crate::common::{endpoint_auth, page_auth, SESSION_COOKIE_NAME};\nuse crate::error::error_page;\nuse crate::middleware::{CookieHostMiddleware, TicketMiddleware};\nuse crate::session::{SessionStore, SharedSessionStorage};\nuse crate::session_handle::warpgate_server_handle_for_request;\n\npub struct HTTPProtocolServer {\n    services: Services,\n}\n\nimpl HTTPProtocolServer {\n    pub async fn new(services: &Services) -> Result<Self> {\n        Ok(HTTPProtocolServer {\n            services: services.clone(),\n        })\n    }\n}\n\nfn make_session_storage() -> SharedSessionStorage {\n    SharedSessionStorage(Arc::new(Mutex::new(Box::<MemoryStorage>::default())))\n}\n\nasync fn load_certificate_and_key<R: IntoTlsCertificateRelativePaths>(\n    from: &R,\n    params: &GlobalParams,\n) -> Result<TlsCertificateAndPrivateKey, RustlsSetupError> {\n    Ok(TlsCertificateAndPrivateKey {\n        certificate: TlsCertificateBundle::from_file(\n            params.paths_relative_to().join(from.certificate_path()),\n        )\n        .await?,\n        private_key: TlsPrivateKey::from_file(params.paths_relative_to().join(from.key_path()))\n            .await?,\n    })\n}\n\nasync fn make_rustls_config(\n    config: &WarpgateConfig,\n    params: &GlobalParams,\n) -> Result<RustlsConfig> {\n    let certificate_and_key = load_certificate_and_key(&config.store.http, params)\n        .await\n        .with_context(|| {\n            format!(\n                \"loading TLS certificate and key: {}\",\n                config.store.http.certificate,\n            )\n        })?;\n\n    let mut cfg = RustlsConfig::new().fallback(certificate_and_key.into());\n    for sni in &config.store.http.sni_certificates {\n        let certificate_and_key = load_certificate_and_key(sni, params)\n            .await\n            .with_context(|| format!(\"loading SNI TLS certificate: {sni:?}\",))?;\n\n        for name in certificate_and_key.certificate.sni_names()? {\n            debug!(?name, source=?sni, \"Adding SNI certificate\");\n            cfg = cfg.certificate(name, certificate_and_key.clone().into());\n        }\n    }\n    Ok(cfg)\n}\n\nimpl ProtocolServer for HTTPProtocolServer {\n    async fn run(self, address: ListenEndpoint) -> Result<()> {\n        let session_storage = make_session_storage();\n        let session_store = SessionStore::new();\n\n        let cache_bust = || {\n            SetHeader::new().overriding(\n                http::header::CACHE_CONTROL,\n                HeaderValue::from_static(\"must-revalidate,no-cache,no-store\"),\n            )\n        };\n\n        let cache_static = || {\n            SetHeader::new().overriding(\n                http::header::CACHE_CONTROL,\n                HeaderValue::from_static(\"max-age=86400\"),\n            )\n        };\n\n        let (cookie_max_age, session_max_age) = {\n            let config = self.services.config.lock().await;\n            (\n                config.store.http.cookie_max_age,\n                config.store.http.session_max_age,\n            )\n        };\n\n        // Set cookie domain to base host (e.g., \".warp.tavahealth.com\") so it works for\n        // the base host and all its subdomains (e.g., \"foo.warp.tavahealth.com\").\n        // This is more restrictive than using the parent domain and ensures cookies only\n        // work for the base host and its subdomains, not sibling domains.\n        let base_cookie_domain: Option<String> = {\n            let config = self.services.config.lock().await;\n            match config.construct_external_url(None, None) {\n                Ok(url) => {\n                    if let Some(host) = url.host_str() {\n                        // Use the base host directly with a leading dot (e.g., \".warp.tavahealth.com\")\n                        // This allows cookies to work for:\n                        // - warp.tavahealth.com (exact match)\n                        // - foo.warp.tavahealth.com (subdomain)\n                        // - bar.warp.tavahealth.com (subdomain)\n                        // But NOT for:\n                        // - tavahealth.com (parent domain)\n                        // - reporting.tavahealth.com (sibling domain)\n                        let domain = format!(\".{}\", host);\n                        tracing::info!(\n                            \"Cookie domain configured: {} (base host: {}) - cookies will work for {} and all its subdomains\",\n                            domain,\n                            host,\n                            host\n                        );\n                        Some(domain)\n                    } else {\n                        tracing::warn!(\"Failed to determine cookie domain - external_host may not be configured. Cookies will be scoped to request host, which may prevent cross-subdomain authentication.\");\n                        None\n                    }\n                }\n                Err(e) => {\n                    tracing::warn!(\"Failed to construct external URL for cookie domain: {:?}. Cookies will be scoped to request host.\", e);\n                    None\n                }\n            }\n        };\n\n        // /@warpgate/ routes\n        let at_warpgate_endpoints = || {\n            let services = self.services.clone();\n            let api_service = {\n                OpenApiService::new(crate::api::get(), \"Warpgate user API\", warpgate_version())\n                    .server(\"/@warpgate/api\")\n            };\n            let openapi_ui_route = api_service.stoplight_elements();\n            let openapi_spec_route = api_service.spec_endpoint();\n            let admin_api_app = admin_api_app().into_endpoint();\n\n            Route::new()\n                .nest(\"/api/playground\", openapi_ui_route)\n                .nest(\"/api\", api_service.with(cache_bust()))\n                .nest(\"/api/openapi.json\", openapi_spec_route)\n                .nest_no_strip(\n                    \"/assets\",\n                    EmbeddedFilesEndpoint::<Assets>::new().with(cache_static()),\n                )\n                .nest(\n                    \"/admin/api\",\n                    endpoint_auth(admin_api_app).with(cache_bust()),\n                )\n                .at(\n                    \"/admin\",\n                    page_auth(EmbeddedFileEndpoint::<Assets>::new(\"src/admin/index.html\"))\n                        .with(cache_bust()),\n                )\n                .at(\n                    \"/api/auth/web-auth-requests/stream\",\n                    endpoint_auth(api::auth::api_get_web_auth_requests_stream),\n                )\n                .at(\n                    \"\",\n                    EmbeddedFileEndpoint::<Assets>::new(\"src/gateway/index.html\")\n                        .with(cache_bust()),\n                )\n                .around({\n                    let services = services.clone();\n                    move |ep, req| {\n                        let services = services.clone();\n                        async move {\n                            let method = req.method().clone();\n                            let url = req.original_uri().clone();\n                            let client_ip = get_client_ip(&req, &services).await;\n\n                            let response = ep.call(req).await.inspect_err(|e| {\n                                log_request_error(&method, &url, client_ip.as_deref(), e);\n                            })?;\n\n                            log_request_result(\n                                &method,\n                                &url,\n                                client_ip.as_deref(),\n                                &response.status(),\n                            );\n                            Ok(response)\n                        }\n                    }\n                })\n        };\n\n        let app = Route::new()\n            .nest(\"/@warpgate\", at_warpgate_endpoints())\n            .nest(\"/_warpgate\", at_warpgate_endpoints())\n            .nest_no_strip(\n                \"/\",\n                page_auth(catchall::catchall_endpoint).around(move |ep, req| async move {\n                    Ok(match ep.call(req).await {\n                        Ok(response) => response.into_response(),\n                        Err(error) => error_page(error).into_response(),\n                    })\n                }),\n            )\n            .around(inject_request_authorization)\n            .around(move |ep, req| async move {\n                let ctx = Data::<&UnauthenticatedRequestContext>::from_request_without_body(&req)\n                    .await?\n                    .clone();\n                let sm = Data::<&Arc<Mutex<SessionStore>>>::from_request_without_body(&req)\n                    .await?\n                    .clone();\n\n                let req = { sm.lock().await.process_request(req).await? };\n                let handle = warpgate_server_handle_for_request(&req).await.ok();\n                let span = match handle {\n                    Some(ref handle) => {\n                        let handle = handle.lock().await;\n                        span_for_request(&req, &ctx.services, Some(&*handle)).await?\n                    }\n                    None => span_for_request(&req, &ctx.services, None).await?,\n                };\n\n                ep.call(req).instrument(span).await\n            })\n            .with(\n                SetHeader::new()\n                    .overriding(http::header::STRICT_TRANSPORT_SECURITY, \"max-age=31536000\"),\n            )\n            .with(TicketMiddleware::new())\n            .with(ServerSession::new(\n                CookieConfig::default()\n                    .secure(false)\n                    .max_age(cookie_max_age)\n                    .name(SESSION_COOKIE_NAME),\n                session_storage.clone(),\n            ))\n            .with(CookieHostMiddleware::new(base_cookie_domain))\n            .data(UnauthenticatedRequestContext {\n                services: self.services.clone(),\n            })\n            .data(session_store.clone())\n            .data(session_storage);\n\n        tokio::spawn(async move {\n            loop {\n                session_store.lock().await.vacuum(session_max_age).await;\n                tokio::time::sleep(Duration::from_secs(60)).await;\n            }\n        });\n\n        let rustls_config = {\n            let config = self.services.config.lock().await;\n            make_rustls_config(&config, &self.services.global_params)\n                .await\n                .context(\"rustls setup\")?\n        };\n\n        Server::new(address.poem_listener().await?.rustls(rustls_config))\n            .run(app)\n            .await?;\n\n        Ok(())\n    }\n\n    fn name(&self) -> &'static str {\n        \"HTTP\"\n    }\n}\n\nimpl Debug for HTTPProtocolServer {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"HTTPProtocolServer\")\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/main.rs",
    "content": "use poem_openapi::OpenApiService;\nuse regex::Regex;\nuse warpgate_common::version::warpgate_version;\nuse warpgate_protocol_http::api;\n\n#[allow(clippy::unwrap_used)]\npub fn main() {\n    let api_service = OpenApiService::new(api::get(), \"Warpgate HTTP proxy\", warpgate_version())\n        .server(\"/@warpgate/api\");\n\n    let spec = api_service.spec();\n    let re = Regex::new(r\"PaginatedResponse<(?P<name>\\w+)>\").unwrap();\n    let spec = re.replace_all(&spec, \"Paginated$name\");\n\n    println!(\"{spec}\");\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/middleware/cookie_host.rs",
    "content": "use cookie::Cookie;\nuse http::uri::Scheme;\nuse poem::{Endpoint, IntoResponse, Middleware, Request, Response};\n\nuse crate::common::{is_localhost_host, SESSION_COOKIE_NAME};\n\n#[derive(Clone)]\npub struct CookieHostMiddleware {\n    base_domain: Option<String>,\n}\n\nimpl CookieHostMiddleware {\n    /// If `base_domain` is Some(\".example.com\"), the session cookie will be\n    /// scoped to that domain (works across subdomains). If None, it falls\n    /// back to the request host (previous behavior).\n    pub fn new(base_domain: Option<String>) -> Self {\n        Self { base_domain }\n    }\n}\n\npub struct CookieHostMiddlewareEndpoint<E: Endpoint> {\n    inner: E,\n    base_domain: Option<String>,\n}\n\nimpl<E: Endpoint> Middleware<E> for CookieHostMiddleware {\n    type Output = CookieHostMiddlewareEndpoint<E>;\n\n    fn transform(&self, inner: E) -> Self::Output {\n        CookieHostMiddlewareEndpoint {\n            inner,\n            base_domain: self.base_domain.clone(),\n        }\n    }\n}\n\nimpl<E: Endpoint> Endpoint for CookieHostMiddlewareEndpoint<E> {\n    type Output = Response;\n\n    async fn call(&self, req: Request) -> poem::Result<Self::Output> {\n        let host = req\n            .header(http::header::HOST)\n            .map(|h| h.split(':').next().unwrap_or(h).to_string())\n            .or_else(|| req.original_uri().host().map(|x| x.to_string()));\n\n        let scheme_https = req.original_uri().scheme() == Some(&Scheme::HTTPS);\n        let header_https = req\n            .header(\"x-forwarded-proto\")\n            .map(|h| h == \"https\")\n            .unwrap_or(false);\n        let is_https = scheme_https || header_https;\n\n        let mut resp = self.inner.call(req).await?.into_response();\n\n        if let Some(host) = host {\n            // Extract all Set-Cookie headers for modification\n            let cookie_values: Vec<String> = {\n                let headers = resp.headers();\n                headers\n                    .get_all(http::header::SET_COOKIE)\n                    .iter()\n                    .filter_map(|v| v.to_str().ok())\n                    .map(|s| s.to_string())\n                    .collect()\n            };\n\n            // Use the cookie crate to parse and modify cookies properly\n            let mut modified_session_cookie: Option<String> = None;\n            for cookie_str in &cookie_values {\n                if let Ok(mut cookie) = Cookie::parse(cookie_str) {\n                    if cookie.name() == SESSION_COOKIE_NAME {\n                        // For localhost/127.0.0.1, omit Domain attribute since browsers won't send cookies with a different domain\n                        let is_localhost = is_localhost_host(&host);\n                        let target_domain = if is_localhost {\n                            None // Omit Domain attribute for localhost - browser will scope to exact host\n                        } else if let Some(ref base) = self.base_domain {\n                            Some(base.clone())\n                        } else {\n                            Some(host.clone())\n                        };\n\n                        // Set or remove Domain attribute using cookie crate methods\n                        if let Some(ref domain) = target_domain {\n                            cookie.set_domain(domain.clone());\n                        } else {\n                            // For localhost, we need to remove the domain attribute\n                            cookie.unset_domain();\n                        }\n\n                        // Add Secure and SameSite=None for HTTPS (required for cross-site cookies)\n                        if is_https {\n                            cookie.set_secure(true);\n                            cookie.set_same_site(cookie::SameSite::None);\n                        }\n\n                        if self.base_domain.is_none() {\n                            tracing::warn!(\n                                \"CookieHostMiddleware: Setting session cookie domain to request host: {} (no base domain configured). This may prevent SSO from working across subdomains. Consider setting 'external_host' in config.\",\n                                host\n                            );\n                        }\n\n                        modified_session_cookie = Some(cookie.to_string());\n                        tracing::debug!(\n                            \"CookieHostMiddleware: Modified cookie - domain={:?}, is_https={}\",\n                            target_domain,\n                            is_https\n                        );\n                        break;\n                    }\n                }\n            }\n\n            if modified_session_cookie.is_none() {\n                tracing::debug!(\n                    \"CookieHostMiddleware: No session cookie found in {} cookie(s)\",\n                    cookie_values.len()\n                );\n            }\n\n            // Replace Set-Cookie headers: modified session cookie + other cookies unchanged\n            if let Some(modified_cookie) = modified_session_cookie {\n                let headers = resp.headers_mut();\n                headers.remove(http::header::SET_COOKIE);\n                if let Ok(header_value) = modified_cookie.parse::<http::HeaderValue>() {\n                    headers.append(http::header::SET_COOKIE, header_value);\n                }\n                for cookie_str in &cookie_values {\n                    if let Ok(cookie) = Cookie::parse(cookie_str) {\n                        if cookie.name() != SESSION_COOKIE_NAME {\n                            if let Ok(header_value) = cookie_str.parse::<http::HeaderValue>() {\n                                headers.append(http::header::SET_COOKIE, header_value);\n                            }\n                        }\n                    }\n                }\n            }\n        }\n        Ok(resp)\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/middleware/mod.rs",
    "content": "mod cookie_host;\nmod ticket;\n\npub use cookie_host::*;\npub use ticket::*;\n"
  },
  {
    "path": "warpgate-protocol-http/src/middleware/ticket.rs",
    "content": "use poem::session::Session;\nuse poem::web::{Data, FromRequest};\nuse poem::{Endpoint, Middleware, Request};\nuse serde::Deserialize;\nuse warpgate_common::Secret;\nuse warpgate_common_http::auth::UnauthenticatedRequestContext;\nuse warpgate_common_http::SessionAuthorization;\nuse warpgate_core::{authorize_ticket, consume_ticket};\n\nuse crate::common::SessionExt;\n\npub struct TicketMiddleware {}\n\nimpl TicketMiddleware {\n    pub fn new() -> Self {\n        TicketMiddleware {}\n    }\n}\n\npub struct TicketMiddlewareEndpoint<E: Endpoint> {\n    inner: E,\n}\n\nimpl<E: Endpoint> Middleware<E> for TicketMiddleware {\n    type Output = TicketMiddlewareEndpoint<E>;\n\n    fn transform(&self, inner: E) -> Self::Output {\n        TicketMiddlewareEndpoint { inner }\n    }\n}\n\n#[derive(Deserialize)]\nstruct QueryParams {\n    #[serde(rename = \"warpgate-ticket\")]\n    ticket: Option<String>,\n}\n\nimpl<E: Endpoint> Endpoint for TicketMiddlewareEndpoint<E> {\n    type Output = E::Output;\n\n    async fn call(&self, req: Request) -> poem::Result<Self::Output> {\n        let mut session_is_temporary = false;\n        let session = <&Session>::from_request_without_body(&req).await?;\n        let session = session.clone();\n\n        let ctx = Data::<&UnauthenticatedRequestContext>::from_request_without_body(&req).await?;\n\n        {\n            let params: QueryParams = req.params()?;\n\n            let mut ticket_value = None;\n            if let Some(t) = params.ticket {\n                ticket_value = Some(t);\n            }\n            for h in req.headers().get_all(http::header::AUTHORIZATION) {\n                let header_value = h.to_str().unwrap_or(\"\").to_string();\n                if let Some((token_type, token_value)) = header_value.split_once(' ') {\n                    if &token_type.to_lowercase() == \"warpgate\" {\n                        ticket_value = Some(token_value.to_string());\n                        session_is_temporary = true;\n                    }\n                }\n            }\n\n            if let Some(ticket) = ticket_value {\n                if let Some((ticket_model, _user_info)) = {\n                    let ticket_secret = Secret::new(ticket);\n                    if let Some((ticket, user_info)) =\n                        authorize_ticket(&ctx.services.db, &ticket_secret).await?\n                    {\n                        consume_ticket(&ctx.services.db, &ticket.id).await?;\n                        Some((ticket, user_info))\n                    } else {\n                        None\n                    }\n                } {\n                    session.set_auth(SessionAuthorization::Ticket {\n                        username: ticket_model.username,\n                        target_name: ticket_model.target,\n                    });\n                }\n            }\n        }\n\n        let resp = self.inner.call(req).await;\n\n        if session_is_temporary {\n            session.clear();\n        }\n\n        resp\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/proxy.rs",
    "content": "use std::str::FromStr;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse cookie::Cookie;\nuse data_encoding::BASE64;\nuse delegate::delegate;\nuse futures::{StreamExt, TryStreamExt};\nuse http::header::HeaderName;\nuse http::uri::{Authority, Scheme};\nuse http::{HeaderValue, Uri};\nuse poem::session::Session;\nuse poem::web::websocket::WebSocket;\nuse poem::{Body, FromRequest, IntoResponse, Request, Response};\nuse tokio_tungstenite::{connect_async_tls_with_config, tungstenite, Connector};\nuse tracing::*;\nuse url::Url;\nuse warpgate_common::helpers::websocket::pump_websocket;\nuse warpgate_common::http_headers::{\n    DONT_FORWARD_HEADERS, X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO,\n};\nuse warpgate_common::{try_block, TargetHTTPOptions, WarpgateError};\nuse warpgate_common_http::logging::{get_client_ip, log_request_result};\nuse warpgate_common_http::{AuthenticatedRequestContext, SessionAuthorization};\nuse warpgate_tls::{configure_tls_connector, TlsMode};\nuse warpgate_web::lookup_built_file;\n\nuse crate::common::SessionExt;\n\nstatic X_WARPGATE_USERNAME: HeaderName = HeaderName::from_static(\"x-warpgate-username\");\nstatic X_WARPGATE_AUTHENTICATION_TYPE: HeaderName =\n    HeaderName::from_static(\"x-warpgate-authentication-type\");\n\ntrait SomeResponse {\n    fn status(&self) -> http::StatusCode;\n    fn headers(&self) -> &http::HeaderMap;\n}\n\nimpl SomeResponse for reqwest::Response {\n    delegate! {\n        to self {\n            fn status(&self) -> http::StatusCode;\n            fn headers(&self) -> &http::HeaderMap;\n        }\n    }\n}\n\nimpl<B> SomeResponse for http::Response<B> {\n    delegate! {\n        to self {\n            fn status(&self) -> http::StatusCode;\n            fn headers(&self) -> &http::HeaderMap;\n        }\n    }\n}\n\ntrait SomeRequestBuilder {\n    fn header<K: Into<HeaderName>, V>(self, k: K, v: V) -> Self\n    where\n        HeaderValue: TryFrom<V>,\n        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>;\n}\n\nimpl SomeRequestBuilder for reqwest::RequestBuilder {\n    fn header<K: Into<HeaderName>, V>(self, k: K, v: V) -> Self\n    where\n        HeaderValue: TryFrom<V>,\n        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,\n    {\n        self.header(k, v)\n    }\n}\n\nimpl SomeRequestBuilder for http::request::Builder {\n    fn header<K: Into<HeaderName>, V>(self, k: K, v: V) -> Self\n    where\n        HeaderValue: TryFrom<V>,\n        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,\n    {\n        self.header(k, v)\n    }\n}\n\nfn construct_uri(req: &Request, options: &TargetHTTPOptions, websocket: bool) -> Result<Uri> {\n    let target_uri = Uri::try_from(options.url.clone())?;\n    let source_uri = req.uri().clone();\n\n    let authority = target_uri\n        .authority()\n        .context(\"No authority in the URL\")?\n        .to_string();\n\n    let authority: Authority = authority.try_into()?;\n    let mut uri = http::uri::Builder::new()\n        .authority(authority)\n        .path_and_query(\n            source_uri\n                .path_and_query()\n                .context(\"No path in the URL\")?\n                .clone(),\n        );\n\n    let scheme = match options.tls.mode {\n        TlsMode::Disabled => &Scheme::HTTP,\n        TlsMode::Preferred => target_uri.scheme().context(\"No scheme in the URL\")?,\n        TlsMode::Required => &Scheme::HTTPS,\n    };\n    uri = uri.scheme(scheme.clone());\n\n    #[allow(clippy::unwrap_used)]\n    if websocket {\n        uri = uri.scheme(\n            Scheme::from_str(if scheme == &Scheme::from_str(\"http\").unwrap() {\n                \"ws\"\n            } else {\n                \"wss\"\n            })\n            .unwrap(),\n        );\n    }\n\n    Ok(uri.build()?)\n}\n\nfn copy_client_response<R: SomeResponse>(\n    client_response: &R,\n    server_response: &mut poem::Response,\n) {\n    let mut headers = client_response.headers().clone();\n    for h in client_response.headers().iter() {\n        if DONT_FORWARD_HEADERS.contains(h.0) {\n            if let http::header::Entry::Occupied(e) = headers.entry(h.0) {\n                e.remove_entry();\n            }\n        }\n    }\n    server_response.headers_mut().extend(headers);\n\n    server_response.set_status(client_response.status());\n}\n\nfn rewrite_request<B: SomeRequestBuilder>(mut req: B, options: &TargetHTTPOptions) -> Result<B> {\n    if let Some(ref headers) = options.headers {\n        for (k, v) in headers {\n            req = req.header(HeaderName::try_from(k)?, v);\n        }\n    }\n    Ok(req)\n}\n\nfn rewrite_response(\n    resp: &mut Response,\n    options: &TargetHTTPOptions,\n    source_uri: &Uri,\n) -> Result<()> {\n    let target_uri = Uri::try_from(options.url.clone())?;\n    let headers = resp.headers_mut();\n\n    if let Some(value) = headers.get_mut(http::header::LOCATION) {\n        let location = Url::parse(&source_uri.to_string())?.join(value.to_str()?)?;\n        let redirect_uri = Uri::try_from(location.to_string())?;\n\n        if redirect_uri.authority() == target_uri.authority() {\n            let old_value = value.clone();\n            *value = Uri::builder()\n                .path_and_query(\n                    redirect_uri\n                        .path_and_query()\n                        .context(\"No path in URL\")?\n                        .clone(),\n                )\n                .build()?\n                .to_string()\n                .parse()?;\n            debug!(\"Rewrote a redirect from {:?} to {:?}\", old_value, value);\n        }\n    }\n\n    if let http::header::Entry::Occupied(mut entry) = headers.entry(http::header::SET_COOKIE) {\n        for value in entry.iter_mut() {\n            try_block!({\n                let mut cookie = Cookie::parse(value.to_str()?)?;\n                cookie.set_expires(cookie::Expiration::Session);\n                *value = cookie.to_string().parse()?;\n            } catch (error: anyhow::Error) {\n                warn!(?error, header=?value, \"Failed to parse response cookie\")\n            })\n        }\n    }\n\n    Ok(())\n}\n\nfn copy_server_request<B: SomeRequestBuilder>(req: &Request, mut target: B) -> B {\n    for k in req.headers().keys() {\n        if DONT_FORWARD_HEADERS.contains(k) {\n            continue;\n        }\n        target = target.header(\n            k.clone(),\n            req.headers()\n                .get_all(k)\n                .iter()\n                .map(|v| v.to_str().map(|x| x.to_string()))\n                .filter_map(|x| x.ok())\n                .collect::<Vec<_>>()\n                .join(\"; \"),\n        );\n    }\n    target\n}\n\nfn inject_forwarding_headers<B: SomeRequestBuilder>(req: &Request, mut target: B) -> Result<B> {\n    #[allow(clippy::unwrap_used)]\n    if let Some(host) = req.headers().get(http::header::HOST) {\n        target = target.header(\n            X_FORWARDED_HOST.clone(),\n            host.to_str()?.split(':').next().unwrap(),\n        );\n    }\n    target = target.header(X_FORWARDED_PROTO.clone(), req.scheme().as_str());\n    if let Some(addr) = req.remote_addr().as_socket_addr() {\n        target = target.header(X_FORWARDED_FOR.clone(), addr.ip().to_string());\n    }\n    Ok(target)\n}\n\nasync fn inject_own_headers<B: SomeRequestBuilder>(req: &Request, mut target: B) -> Result<B> {\n    let session = <&Session>::from_request_without_body(req).await?;\n    if let Some(auth) = session.get_auth() {\n        target = target.header(&X_WARPGATE_USERNAME, auth.username()).header(\n            &X_WARPGATE_AUTHENTICATION_TYPE,\n            match auth {\n                SessionAuthorization::Ticket { .. } => \"ticket\",\n                SessionAuthorization::User { .. } => \"user\",\n            },\n        );\n    }\n    Ok(target)\n}\n\npub async fn proxy_normal_request(\n    req: &Request,\n    ctx: &AuthenticatedRequestContext,\n    body: Body,\n    options: &TargetHTTPOptions,\n) -> poem::Result<Response> {\n    let uri = construct_uri(req, options, false)?;\n\n    tracing::debug!(\"URI: {:?}\", uri);\n\n    let mut client = reqwest::Client::builder()\n        .gzip(true)\n        .redirect(reqwest::redirect::Policy::none())\n        .connection_verbose(true);\n\n    if let TlsMode::Required = options.tls.mode {\n        client = client.https_only(true);\n    }\n\n    client = client.redirect(reqwest::redirect::Policy::custom({\n        let tls_mode = options.tls.mode;\n        let uri = uri.clone();\n        move |attempt| {\n            if tls_mode == TlsMode::Preferred\n                && uri.scheme() == Some(&Scheme::HTTP)\n                && attempt.url().scheme() == \"https\"\n            {\n                debug!(\"Following HTTP->HTTPS redirect\");\n                attempt.follow()\n            } else {\n                attempt.stop()\n            }\n        }\n    }));\n\n    if !options.tls.verify {\n        client = client.danger_accept_invalid_certs(true);\n    }\n\n    let client = client.build().context(\"Could not build request\")?;\n\n    let (authorization_header, uri) = extract_basic_auth(uri)?;\n\n    let mut client_request = client.request(req.method().into(), uri.to_string());\n\n    client_request = copy_server_request(req, client_request);\n    client_request = inject_forwarding_headers(req, client_request)?;\n    client_request = inject_own_headers(req, client_request).await?;\n    client_request = rewrite_request(client_request, options)?;\n    if let Some(authorization_header) = authorization_header {\n        client_request = client_request.header(http::header::AUTHORIZATION, authorization_header);\n    }\n\n    client_request = client_request.body(reqwest::Body::wrap_stream(body.into_bytes_stream()));\n\n    let client_request = client_request.build().context(\"Could not build request\")?;\n    let client_response = client\n        .execute(client_request)\n        .await\n        .map_err(|e| anyhow::anyhow!(\"Could not execute request: {e}\"))?;\n    let status = client_response.status();\n\n    let mut response: Response = \"\".into();\n\n    copy_client_response(&client_response, &mut response);\n    copy_client_body(client_response, &mut response).await?;\n\n    log_request_result(\n        req.method(),\n        req.original_uri(),\n        get_client_ip(req, &ctx.services).await.as_deref(),\n        &status,\n    );\n\n    rewrite_response(&mut response, options, &uri)?;\n    Ok(response)\n}\n\nasync fn copy_client_body(\n    client_response: reqwest::Response,\n    response: &mut Response,\n) -> Result<()> {\n    if response.content_type().map(|c| c.starts_with(\"text/html\")) == Some(true)\n        && response.status() == 200\n    {\n        copy_client_body_and_embed(client_response, response).await?;\n        return Ok(());\n    }\n\n    response.set_body(Body::from_bytes_stream(\n        client_response\n            .bytes_stream()\n            .map_err(std::io::Error::other),\n    ));\n    Ok(())\n}\n\nasync fn copy_client_body_and_embed(\n    client_response: reqwest::Response,\n    response: &mut Response,\n) -> Result<()> {\n    let content = client_response.text().await?;\n\n    let script_manifest = lookup_built_file(\"src/embed/index.ts\")?;\n\n    let mut inject = format!(\n        r#\"<script type=\"module\" src=\"/@warpgate/{}\"></script>\"#,\n        script_manifest.file\n    );\n    for css_file in script_manifest.css.unwrap_or_default() {\n        inject += &format!(r#\"<link rel=\"stylesheet\" href=\"/@warpgate/{css_file}\" />\"#,);\n    }\n\n    let before = \"</head>\";\n    let content = content.replacen(before, &format!(\"{inject}{before}\"), 1);\n\n    response.headers_mut().remove(http::header::CONTENT_LENGTH);\n    response\n        .headers_mut()\n        .remove(http::header::CONTENT_ENCODING);\n    response.headers_mut().remove(http::header::CONTENT_TYPE);\n    response\n        .headers_mut()\n        .remove(http::header::TRANSFER_ENCODING);\n    response.headers_mut().insert(\n        http::header::CONTENT_TYPE,\n        \"text/html; charset=utf-8\".parse()?,\n    );\n    response.set_body(content);\n    Ok(())\n}\n\npub async fn proxy_websocket_request(\n    req: &Request,\n    ws: WebSocket,\n    options: &TargetHTTPOptions,\n) -> poem::Result<impl IntoResponse> {\n    let uri = construct_uri(req, options, true)?;\n    proxy_ws_inner(req, ws, uri.clone(), options)\n        .await\n        .map_err(|error| {\n            tracing::error!(?uri, ?error, \"WebSocket proxy failed\");\n            error\n        })\n}\n\n/// Remove the username/password from the URL before using it for the Host header\nfn extract_basic_auth(uri: Uri) -> anyhow::Result<(Option<HeaderValue>, Uri)> {\n    let uri_authority = uri\n        .authority()\n        .ok_or(WarpgateError::NoHostInUrl)?\n        .to_string();\n    let parts = uri_authority.split('@').collect::<Vec<_>>();\n\n    let host = parts.last().context(\"URL authority is empty\")?;\n\n    let uri = {\n        let mut parts = uri.into_parts();\n        parts.authority = Some(Authority::from_str(host)?);\n        Uri::from_parts(parts)?\n    };\n\n    if parts.len() == 1 {\n        return Ok((None, uri));\n    }\n\n    #[allow(clippy::indexing_slicing)] // checked\n    let creds = parts[0];\n\n    let auth_header = format!(\"Basic {}\", BASE64.encode(creds.as_bytes()));\n\n    let auth_value = HeaderValue::from_str(&auth_header)?;\n\n    Ok((Some(auth_value), uri))\n}\n\nasync fn proxy_ws_inner(\n    req: &Request,\n    ws: WebSocket,\n    uri: Uri,\n    options: &TargetHTTPOptions,\n) -> poem::Result<impl IntoResponse> {\n    let (authorization_header, uri) = extract_basic_auth(uri)?;\n    let mut client_request = http::request::Builder::new()\n        .uri(uri.clone())\n        .header(http::header::CONNECTION, \"Upgrade\")\n        .header(http::header::UPGRADE, \"websocket\")\n        .header(http::header::SEC_WEBSOCKET_VERSION, \"13\")\n        .header(\n            http::header::SEC_WEBSOCKET_KEY,\n            tungstenite::handshake::client::generate_key(),\n        )\n        // tungstenite requires an explicit Host header\n        .header(\n            http::header::HOST,\n            uri.authority()\n                .ok_or(WarpgateError::NoHostInUrl)\n                .context(\"no authority in the URL\")?\n                .to_string(),\n        );\n\n    if let Some(authorization_header) = authorization_header {\n        client_request = client_request.header(http::header::AUTHORIZATION, authorization_header);\n    }\n\n    client_request = copy_server_request(req, client_request);\n    client_request = inject_forwarding_headers(req, client_request)?;\n    client_request = inject_own_headers(req, client_request).await?;\n    client_request = rewrite_request(client_request, options)?;\n\n    let tls_config = configure_tls_connector(!options.tls.verify, false, None)\n        .await\n        .map_err(poem::error::InternalServerError)?;\n    let connector = Connector::Rustls(Arc::new(tls_config));\n\n    let (client, client_response) = connect_async_tls_with_config(\n        client_request\n            .body(())\n            .map_err(poem::error::InternalServerError)?,\n        None,\n        true,\n        Some(connector),\n    )\n    .await\n    .map_err(poem::error::BadGateway)?;\n\n    tracing::info!(\"{:?} {:?} - WebSocket\", client_response.status(), uri);\n\n    let mut response = ws\n        .on_upgrade(|socket| async move {\n            let (client_sink, client_source) = client.split();\n            let (server_sink, server_source) = socket.split();\n\n            if let Err(error) = {\n                let server_to_client =\n                    tokio::spawn(pump_websocket(server_source, client_sink, |msg| {\n                        Box::pin(async {\n                            tracing::debug!(\"Server: {:?}\", msg);\n                            anyhow::Ok(msg)\n                        })\n                    }));\n\n                let client_to_server =\n                    tokio::spawn(pump_websocket(client_source, server_sink, |msg| {\n                        Box::pin(async {\n                            tracing::debug!(\"Client: {:?}\", msg);\n                            anyhow::Ok(msg)\n                        })\n                    }));\n\n                server_to_client.await??;\n                client_to_server.await??;\n                debug!(\"Closing Websocket stream\");\n\n                Ok::<_, anyhow::Error>(())\n            } {\n                error!(?error, \"Websocket stream error\");\n            }\n            Ok::<_, anyhow::Error>(())\n        })\n        .into_response();\n\n    copy_client_response(&client_response, &mut response);\n    rewrite_response(&mut response, options, &uri)?;\n    Ok(response)\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/session.rs",
    "content": "use std::collections::{BTreeMap, HashMap};\nuse std::sync::{Arc, Weak};\nuse std::time::{Duration, Instant};\n\nuse poem::session::{MemoryStorage, Session, SessionStorage};\nuse poem::web::{Data, RemoteAddr};\nuse poem::{FromRequest, Request};\nuse serde_json::Value;\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse warpgate_common::SessionId;\nuse warpgate_common_http::auth::UnauthenticatedRequestContext;\nuse warpgate_core::{SessionStateInit, State, WarpgateServerHandle};\n\nuse crate::common::PROTOCOL_NAME;\nuse crate::session_handle::{HttpSessionHandle, SessionHandleCommand};\n\n#[derive(Clone)]\npub struct SharedSessionStorage(pub Arc<Mutex<Box<MemoryStorage>>>);\n\nstatic POEM_SESSION_ID_SESSION_KEY: &str = \"poem_session_id\";\n\nimpl SessionStorage for SharedSessionStorage {\n    async fn load_session<'a>(\n        &'a self,\n        session_id: &'a str,\n    ) -> poem::Result<Option<BTreeMap<String, Value>>> {\n        self.0.lock().await.load_session(session_id).await.map(|o| {\n            o.map(|mut s| {\n                s.insert(\n                    POEM_SESSION_ID_SESSION_KEY.to_string(),\n                    session_id.to_string().into(),\n                );\n                s\n            })\n        })\n    }\n\n    /// Insert or update a session.\n    async fn update_session<'a>(\n        &'a self,\n        session_id: &'a str,\n        entries: &'a BTreeMap<String, Value>,\n        expires: Option<Duration>,\n    ) -> poem::Result<()> {\n        self.0\n            .lock()\n            .await\n            .update_session(session_id, entries, expires)\n            .await\n    }\n\n    /// Remove a session by session id.\n    async fn remove_session<'a>(&'a self, session_id: &'a str) -> poem::Result<()> {\n        self.0.lock().await.remove_session(session_id).await\n    }\n}\n\npub struct SessionStore {\n    session_handles: HashMap<SessionId, Arc<Mutex<WarpgateServerHandle>>>,\n    session_timestamps: HashMap<SessionId, Instant>,\n    this: Weak<Mutex<SessionStore>>,\n}\n\nstatic SESSION_ID_SESSION_KEY: &str = \"session_id\";\nstatic REQUEST_COUNTER_SESSION_KEY: &str = \"request_counter\";\n\nimpl SessionStore {\n    pub fn new() -> Arc<Mutex<Self>> {\n        Arc::new_cyclic(|me| {\n            Mutex::new(Self {\n                session_handles: HashMap::new(),\n                session_timestamps: HashMap::new(),\n                this: me.clone(),\n            })\n        })\n    }\n\n    pub async fn process_request(&mut self, req: Request) -> poem::Result<Request> {\n        let session = <&Session>::from_request_without_body(&req).await?;\n\n        let request_counter = session.get::<u64>(REQUEST_COUNTER_SESSION_KEY).unwrap_or(0);\n        session.set(REQUEST_COUNTER_SESSION_KEY, request_counter + 1);\n\n        if let Some(session_id) = session.get::<SessionId>(SESSION_ID_SESSION_KEY) {\n            self.session_timestamps.insert(session_id, Instant::now());\n            // } else if request_counter == 5 {\n            // Start logging sessions when they've got 5 requests\n            // self.create_handle_for(&req).await?;\n        };\n\n        Ok(req)\n    }\n\n    pub async fn create_handle_for(\n        &mut self,\n        req: &Request,\n        ctx: &UnauthenticatedRequestContext,\n    ) -> poem::Result<Arc<Mutex<WarpgateServerHandle>>> {\n        let session = <&Session>::from_request_without_body(req).await?;\n\n        if let Some(handle) = self.handle_for(session) {\n            return Ok(handle);\n        }\n\n        let remote_address = <&RemoteAddr>::from_request_without_body(req).await?;\n        let session_storage = Data::<&SharedSessionStorage>::from_request_without_body(req).await?;\n\n        let (session_handle, mut session_handle_rx) = HttpSessionHandle::new();\n\n        let server_handle = State::register_session(\n            &ctx.services.state,\n            &PROTOCOL_NAME,\n            SessionStateInit {\n                remote_address: remote_address.0.as_socket_addr().cloned(),\n                handle: Box::new(session_handle),\n            },\n        )\n        .await?;\n\n        let id = server_handle.lock().await.id();\n        self.session_handles.insert(id, server_handle.clone());\n\n        session.set(SESSION_ID_SESSION_KEY, id);\n\n        let Some(this) = self.this.upgrade() else {\n            return Err(anyhow::anyhow!(\"Invalid session state\").into());\n        };\n        tokio::spawn({\n            let session_storage = (*session_storage).clone();\n            let poem_session_id: Option<String> = session.get(POEM_SESSION_ID_SESSION_KEY);\n            async move {\n                while let Some(command) = session_handle_rx.recv().await {\n                    match command {\n                        SessionHandleCommand::Close => {\n                            if let Some(ref poem_session_id) = poem_session_id {\n                                let _ = session_storage.remove_session(poem_session_id).await;\n                            }\n                            info!(%id, \"Removed HTTP session\");\n                            let mut that = this.lock().await;\n                            that.session_handles.remove(&id);\n                            that.session_timestamps.remove(&id);\n                        }\n                    }\n                }\n                Ok::<_, anyhow::Error>(())\n            }\n        });\n\n        self.session_timestamps.insert(id, Instant::now());\n\n        Ok(server_handle)\n    }\n\n    pub fn handle_for(&self, session: &Session) -> Option<Arc<Mutex<WarpgateServerHandle>>> {\n        session\n            .get::<SessionId>(SESSION_ID_SESSION_KEY)\n            .and_then(|id| self.session_handles.get(&id).cloned())\n    }\n\n    pub fn remove_session(&mut self, session: &Session) {\n        if let Some(id) = session.get::<SessionId>(SESSION_ID_SESSION_KEY) {\n            self.session_handles.remove(&id);\n            self.session_timestamps.remove(&id);\n        }\n    }\n\n    pub async fn vacuum(&mut self, session_max_age: Duration) {\n        let now = Instant::now();\n        let mut to_remove = vec![];\n        for (id, timestamp) in self.session_timestamps.iter() {\n            if now.duration_since(*timestamp) > session_max_age {\n                to_remove.push(*id);\n            }\n        }\n        for id in to_remove {\n            self.session_handles.remove(&id);\n            self.session_timestamps.remove(&id);\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-http/src/session_handle.rs",
    "content": "use std::any::type_name;\nuse std::sync::Arc;\n\nuse poem::error::GetDataError;\nuse poem::session::Session;\nuse poem::web::Data;\nuse poem::{FromRequest, Request};\nuse tokio::sync::{mpsc, Mutex};\nuse warpgate_core::{SessionHandle, WarpgateServerHandle};\n\nuse crate::session::SessionStore;\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub enum SessionHandleCommand {\n    Close,\n}\n\npub struct HttpSessionHandle {\n    sender: mpsc::UnboundedSender<SessionHandleCommand>,\n}\n\nimpl HttpSessionHandle {\n    pub fn new() -> (Self, mpsc::UnboundedReceiver<SessionHandleCommand>) {\n        let (sender, receiver) = mpsc::unbounded_channel();\n        (HttpSessionHandle { sender }, receiver)\n    }\n}\n\nimpl SessionHandle for HttpSessionHandle {\n    fn close(&mut self) {\n        let _ = self.sender.send(SessionHandleCommand::Close);\n    }\n}\n\npub async fn warpgate_server_handle_for_request(\n    req: &Request,\n) -> poem::Result<Arc<Mutex<WarpgateServerHandle>>> {\n    let sm = Data::<&Arc<Mutex<SessionStore>>>::from_request_without_body(req).await?;\n    let session = <&Session>::from_request_without_body(req).await?;\n    Ok(sm\n        .lock()\n        .await\n        .handle_for(session)\n        .ok_or_else(|| GetDataError(type_name::<WarpgateServerHandle>()))?)\n}\n"
  },
  {
    "path": "warpgate-protocol-kubernetes/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-protocol-kubernetes\"\nversion = \"0.22.0\"\n\n[dependencies]\nanyhow.workspace = true\nasync-trait = { version = \"0.1\", default-features = false }\nbase64 = { version = \"0.22\", default-features = false }\nbytes.workspace = true\nchrono = { version = \"0.4\", default-features = false, features = [\"serde\"] }\ndashmap = { version = \"6.0\", default-features = false }\nfutures.workspace = true\nhttp = { version = \"1.0\", default-features = false }\nlazy_static = { version = \"1.4\", default-features = false }\nmd5 = { version = \"0.7\", default-features = false }\npoem.workspace = true\npoem-openapi.workspace = true\nregex.workspace = true\nreqwest.workspace = true\nrustls.workspace = true\nsea-orm.workspace = true\nsecrecy = { version = \"0.10\", default-features = false }\nserde = { version = \"1.0\", features = [\"derive\"], default-features = false }\nserde_json = { version = \"1.0\", default-features = false }\nthiserror.workspace = true\ntokio.workspace = true\ntokio-rustls = { version = \"0.26\", default-features = false }\ntracing.workspace = true\nurl = { version = \"2.0\", default-features = false }\nuuid.workspace = true\nwarpgate-common = { version = \"*\", path = \"../warpgate-common\", default-features = false }\nwarpgate-common-http = { version = \"*\", path = \"../warpgate-common-http\", default-features = false }\nwarpgate-core = { version = \"*\", path = \"../warpgate-core\", default-features = false }\nwarpgate-db-entities = { version = \"*\", path = \"../warpgate-db-entities\", default-features = false }\nwarpgate-tls = { version = \"*\", path = \"../warpgate-tls\", default-features = false }\nwarpgate-ca = { version = \"*\", path = \"../warpgate-ca\", default-features = false }\ntokio-tungstenite.workspace = true\nreqwest-websocket.workspace = true\n"
  },
  {
    "path": "warpgate-protocol-kubernetes/src/correlator.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\n\nuse poem::Request;\nuse tokio::sync::Mutex;\nuse warpgate_common::auth::AuthStateUserInfo;\nuse warpgate_common::WarpgateError;\nuse warpgate_common_http::logging::get_client_ip;\nuse warpgate_core::{Services, SessionStateInit, State, WarpgateServerHandle};\n\nuse crate::session_handle::KubernetesSessionHandle;\n\ntype CorrelationKey = (String, String, Option<String>); // (username, target_name, ip)\n\npub struct RequestCorrelator {\n    handles: HashMap<CorrelationKey, (Arc<Mutex<WarpgateServerHandle>>, Instant)>,\n    services: Services,\n}\n\nimpl RequestCorrelator {\n    pub fn new(services: &Services) -> Arc<Mutex<Self>> {\n        let this = Arc::new(Mutex::new(Self {\n            handles: HashMap::new(),\n            services: services.clone(),\n        }));\n        Self::spawn_vacuum_task(this.clone());\n        this\n    }\n\n    pub async fn session_for_request(\n        &mut self,\n        request: &Request,\n        user_info: &AuthStateUserInfo,\n        target_name: &str,\n    ) -> Result<Arc<Mutex<WarpgateServerHandle>>, WarpgateError> {\n        let key = self\n            .correlation_key_for_request(request, user_info, target_name)\n            .await?;\n        let now = Instant::now();\n        if let Some((handle, _created)) = self.handles.get(&key) {\n            // Optionally, could update timestamp for LRU\n            return Ok(handle.clone());\n        }\n\n        let ip = get_client_ip(request, &self.services).await;\n\n        let handle = State::register_session(\n            &self.services.state,\n            &crate::PROTOCOL_NAME,\n            SessionStateInit {\n                remote_address: ip.and_then(|x| x.parse().ok()),\n                handle: Box::new(KubernetesSessionHandle),\n            },\n        )\n        .await?;\n        self.handles.insert(key, (handle.clone(), now));\n        Ok(handle)\n    }\n\n    async fn correlation_key_for_request(\n        &self,\n        request: &Request,\n        user_info: &AuthStateUserInfo,\n        target_name: &str,\n    ) -> Result<CorrelationKey, WarpgateError> {\n        let ip = get_client_ip(request, &self.services).await;\n        Ok((user_info.username.clone(), target_name.into(), ip))\n    }\n\n    /// Remove handles older than session_max_age\n    pub async fn vacuum(&mut self) {\n        let max_age = self\n            .services\n            .config\n            .lock()\n            .await\n            .store\n            .kubernetes\n            .session_max_age;\n        let now = Instant::now();\n        self.handles\n            .retain(|_, (_, created)| now.duration_since(*created) < max_age);\n    }\n\n    /// Spawns a background task to periodically call vacuum\n    fn spawn_vacuum_task(this: Arc<Mutex<Self>>) {\n        let interval = Duration::from_secs(60);\n        tokio::spawn(async move {\n            loop {\n                tokio::time::sleep(interval).await;\n                let mut guard = this.lock().await;\n                guard.vacuum().await;\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-kubernetes/src/lib.rs",
    "content": "use std::fmt::Debug;\n\nuse anyhow::Result;\nuse warpgate_common::{ListenEndpoint, ProtocolName};\nuse warpgate_core::{ProtocolServer, Services};\n\nmod correlator;\npub mod recording;\nmod server;\nmod session_handle;\npub use server::run_server;\n\npub static PROTOCOL_NAME: ProtocolName = \"Kubernetes\";\n\n#[derive(Clone)]\npub struct KubernetesProtocolServer {\n    services: Services,\n}\n\nimpl KubernetesProtocolServer {\n    pub async fn new(services: &Services) -> Result<Self> {\n        Ok(Self {\n            services: services.clone(),\n        })\n    }\n}\n\nimpl ProtocolServer for KubernetesProtocolServer {\n    async fn run(self, address: ListenEndpoint) -> Result<()> {\n        run_server(self.services, address).await\n    }\n\n    fn name(&self) -> &'static str {\n        \"Kubernetes\"\n    }\n}\n\nimpl Debug for KubernetesProtocolServer {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"KubernetesProtocolServer\").finish()\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-kubernetes/src/recording.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse bytes::Bytes;\nuse chrono::{DateTime, Utc};\nuse poem_openapi::Object;\nuse regex::Regex;\nuse serde::{Deserialize, Serialize};\nuse tokio::sync::Mutex;\nuse url::Url;\nuse warpgate_common::SessionId;\nuse warpgate_core::recordings::{Recorder, RecordingWriter, SessionRecordings, TerminalRecorder};\nuse warpgate_db_entities::Recording::RecordingKind;\n\n#[derive(Debug, Object)]\n#[oai(rename = \"KubernetesRecordingItem\")]\npub struct KubernetesRecordingItemApiObject {\n    pub timestamp: DateTime<Utc>,\n    pub request_method: String,\n    pub request_path: String,\n    pub request_body: serde_json::Value,\n    pub response_status: Option<u16>,\n    pub response_body: serde_json::Value,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct KubernetesRecordingItem {\n    pub timestamp: DateTime<Utc>,\n    pub request_method: String,\n    pub request_path: String,\n    pub request_headers: std::collections::HashMap<String, String>,\n    #[serde(with = \"warpgate_common::helpers::serde_base64\")]\n    pub request_body: Bytes,\n    pub response_status: Option<u16>,\n    pub response_body: Option<Vec<u8>>,\n}\n\nimpl From<KubernetesRecordingItem> for KubernetesRecordingItemApiObject {\n    fn from(item: KubernetesRecordingItem) -> Self {\n        KubernetesRecordingItemApiObject {\n            timestamp: item.timestamp,\n            request_method: item.request_method,\n            request_path: item.request_path,\n            request_body: serde_json::from_slice(&item.request_body[..])\n                .unwrap_or(serde_json::Value::Null),\n            response_status: item.response_status,\n            response_body: item\n                .response_body\n                .and_then(|body| serde_json::from_slice(&body[..]).ok())\n                .unwrap_or(serde_json::Value::Null),\n        }\n    }\n}\n\npub struct KubernetesRecorder {\n    writer: RecordingWriter,\n}\n\nimpl KubernetesRecorder {\n    async fn write_item(\n        &mut self,\n        item: &KubernetesRecordingItem,\n    ) -> Result<(), warpgate_core::recordings::Error> {\n        let mut serialized_item =\n            serde_json::to_vec(&item).map_err(warpgate_core::recordings::Error::Serialization)?;\n        serialized_item.push(b'\\n');\n        self.writer.write(&serialized_item).await?;\n        Ok(())\n    }\n\n    pub async fn record_response(\n        &mut self,\n        method: &str,\n        path: &str,\n        headers: std::collections::HashMap<String, String>,\n        request_body: &[u8],\n        status: u16,\n        response_body: &[u8],\n    ) -> Result<(), warpgate_core::recordings::Error> {\n        self.write_item(&KubernetesRecordingItem {\n            timestamp: Utc::now(),\n            request_method: method.to_string(),\n            request_path: path.to_string(),\n            request_headers: headers,\n            request_body: Bytes::from(request_body.to_vec()),\n            response_status: Some(status),\n            response_body: Some(response_body.to_vec()),\n        })\n        .await\n    }\n}\n\nimpl Recorder for KubernetesRecorder {\n    fn kind() -> RecordingKind {\n        RecordingKind::Kubernetes\n    }\n\n    fn new(writer: RecordingWriter) -> Self {\n        KubernetesRecorder { writer }\n    }\n}\n\n// ----------\n\n#[derive(Serialize, Deserialize, Debug)]\n#[serde(tag = \"type\")]\npub enum SessionRecordingMetadata {\n    #[serde(rename = \"kubernetes-api\")]\n    Api,\n    #[serde(rename = \"kubernetes-exec\")]\n    Exec {\n        namespace: String,\n        pod: String,\n        container: String,\n        command: String,\n    },\n    #[serde(rename = \"kubernetes-attach\")]\n    Attach {\n        namespace: String,\n        pod: String,\n        container: String,\n    },\n}\n\npub async fn start_recording_api(\n    session_id: &SessionId,\n    recordings: &Arc<Mutex<SessionRecordings>>,\n) -> anyhow::Result<KubernetesRecorder> {\n    let mut recordings = recordings.lock().await;\n    recordings\n        .start::<KubernetesRecorder, _>(\n            session_id,\n            Some(\"api\".into()),\n            SessionRecordingMetadata::Api,\n        )\n        .await\n        .context(\"starting recording\")\n}\n\npub async fn start_recording_exec(\n    session_id: &SessionId,\n    recordings: &Arc<Mutex<SessionRecordings>>,\n    metadata: Option<SessionRecordingMetadata>,\n) -> anyhow::Result<TerminalRecorder> {\n    let mut recordings = recordings.lock().await;\n    recordings\n        .start::<TerminalRecorder, _>(session_id, None, metadata)\n        .await\n        .context(\"starting recording\")\n}\n\npub fn deduce_exec_recording_metadata(target_url: &Url) -> Option<SessionRecordingMetadata> {\n    let path = target_url.path();\n    let exec_url_regex =\n        Regex::new(r\"^/api/v1/namespaces/([^/]+)/pods/([^/]+)/(exec|attach)$\").unwrap();\n    if let Some(captures) = exec_url_regex.captures(path) {\n        let namespace = captures.get(1).map_or(\"unknown\", |m| m.as_str()).into();\n        let pod = captures.get(2).map_or(\"unknown\", |m| m.as_str()).into();\n        let operation = captures.get(3).map_or(\"unknown\", |m| m.as_str());\n        let query = target_url.query().unwrap_or_default();\n        let parsed_query: HashMap<_, _> = url::form_urlencoded::parse(query.as_bytes()).collect();\n        let command = parsed_query\n            .get(\"command\")\n            .cloned()\n            .unwrap_or(\"unknown\".into())\n            .into();\n        let container = parsed_query\n            .get(\"container\")\n            .cloned()\n            .unwrap_or(\"unknown\".into())\n            .into();\n        return match operation {\n            \"exec\" => Some(SessionRecordingMetadata::Exec {\n                namespace,\n                pod,\n                container,\n                command,\n            }),\n            \"attach\" => Some(SessionRecordingMetadata::Attach {\n                namespace,\n                pod,\n                container,\n            }),\n            _ => None,\n        };\n    }\n    None\n}\n"
  },
  {
    "path": "warpgate-protocol-kubernetes/src/server/auth.rs",
    "content": "use anyhow::{Context, Result};\nuse poem::Request;\nuse sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};\nuse tracing::*;\nuse warpgate_ca::{deserialize_certificate, serialize_certificate_serial};\nuse warpgate_common::auth::AuthStateUserInfo;\nuse warpgate_common::{Target, TargetKubernetesOptions, TargetOptions, User};\nuse warpgate_core::{ConfigProvider, Services};\nuse warpgate_db_entities::{CertificateCredential, CertificateRevocation};\n\nuse crate::server::client_certs::RequestCertificateExt;\n\npub async fn authenticate_and_get_target(\n    req: &Request,\n    target_name: &str,\n    services: &Services,\n) -> poem::Result<(AuthStateUserInfo, Target)> {\n    // Check for Bearer token authentication (API tokens)\n    if let Some(auth_header) = req.headers().get(\"authorization\") {\n        if let Ok(auth_str) = auth_header.to_str() {\n            if let Some(token) = auth_str.strip_prefix(\"Bearer \") {\n                let mut config_provider = services.config_provider.lock().await;\n                if let Ok(Some(user)) = config_provider.validate_api_token(token).await {\n                    // Look up the specific target by name from the URL\n                    let targets = config_provider\n                        .list_targets()\n                        .await\n                        .context(\"listing targets\")?;\n\n                    // Find the target with the specified name\n                    for target in targets {\n                        if target.name == target_name\n                            && matches!(target.options, TargetOptions::Kubernetes(_))\n                        {\n                            if config_provider\n                                .authorize_target(&user.username, &target.name)\n                                .await\n                                .unwrap_or(false)\n                            {\n                                return Ok(((&user).into(), target));\n                            } else {\n                                return Err(poem::Error::from_string(\n                                    format!(\"Access denied to target: {}\", target_name),\n                                    poem::http::StatusCode::FORBIDDEN,\n                                ));\n                            }\n                        }\n                    }\n\n                    return Err(poem::Error::from_string(\n                        format!(\"Kubernetes target not found: {}\", target_name),\n                        poem::http::StatusCode::NOT_FOUND,\n                    ));\n                }\n            }\n        }\n    }\n\n    // Check for client certificate authentication\n    // Use certificate extracted by middleware if present\n    if let Some(client_cert) = req.client_certificate() {\n        debug!(\"Found client certificate from middleware, validating against database\");\n\n        match validate_client_certificate(&client_cert.der_bytes, services).await {\n            Ok(Some(user_info)) => {\n                // Look up the specific target by name from the URL\n                let mut config_provider = services.config_provider.lock().await;\n                let targets = config_provider\n                    .list_targets()\n                    .await\n                    .context(\"listing targets\")?;\n\n                // Find the target with the specified name\n                for target in targets {\n                    if target.name == target_name\n                        && matches!(target.options, TargetOptions::Kubernetes(_))\n                    {\n                        if config_provider\n                            .authorize_target(&user_info.username, &target.name)\n                            .await\n                            .unwrap_or(false)\n                        {\n                            return Ok((user_info, target));\n                        } else {\n                            return Err(poem::Error::from_string(\n                                format!(\"Access denied to target: {}\", target_name),\n                                poem::http::StatusCode::FORBIDDEN,\n                            ));\n                        }\n                    }\n                }\n\n                return Err(poem::Error::from_string(\n                    format!(\"Kubernetes target not found: {}\", target_name),\n                    poem::http::StatusCode::NOT_FOUND,\n                ));\n            }\n            Ok(None) => {\n                debug!(\"Client certificate provided but not found in database\");\n            }\n            Err(e) => {\n                warn!(error = %e, \"Error validating client certificate\");\n            }\n        }\n    } else {\n        debug!(\"No client certificate provided in TLS connection\");\n    }\n\n    // Return unauthorized if no valid authentication found\n    Err(poem::Error::from_string(\n        \"Unauthorized: Please provide either a valid Bearer token or a client certificate\",\n        poem::http::StatusCode::UNAUTHORIZED,\n    ))\n}\n\npub fn create_authenticated_client(\n    k8s_options: &TargetKubernetesOptions,\n    _auth_user: &Option<String>,\n    _services: &Services,\n) -> anyhow::Result<reqwest::ClientBuilder> {\n    debug!(\n        server_url = ?k8s_options.cluster_url,\n        auth_kind = ?k8s_options.auth,\n        tls_config = ?k8s_options.tls,\n        \"Creating authenticated Kubernetes client\"\n    );\n\n    // Create HTTP client with the configuration\n    let mut client_builder = reqwest::Client::builder();\n\n    if !k8s_options.tls.verify {\n        client_builder = client_builder.danger_accept_invalid_certs(true);\n    }\n\n    match &k8s_options.auth {\n        warpgate_common::KubernetesTargetAuth::Token(auth) => {\n            let mut headers = reqwest::header::HeaderMap::new();\n            headers.insert(\n                reqwest::header::AUTHORIZATION,\n                reqwest::header::HeaderValue::from_str(&format!(\n                    \"Bearer {}\",\n                    auth.token.expose_secret()\n                ))\n                .context(\"setting Authorization header\")?,\n            );\n            client_builder = client_builder.default_headers(headers);\n        }\n        warpgate_common::KubernetesTargetAuth::Certificate(auth) => {\n            // Expect PEM certificate and PEM private key in the auth config\n            // Combine into a single PEM bundle for reqwest::Identity\n            let cert_pem = auth.certificate.expose_secret();\n            let key_pem = auth.private_key.expose_secret();\n            let mut pem_bundle = String::new();\n            pem_bundle.push_str(cert_pem);\n            if !pem_bundle.ends_with('\\n') {\n                pem_bundle.push('\\n');\n            }\n            pem_bundle.push_str(key_pem);\n            if !pem_bundle.ends_with('\\n') {\n                pem_bundle.push('\\n');\n            }\n\n            let identity = reqwest::Identity::from_pem(pem_bundle.as_bytes())\n                .context(\"Invalid client certificate/key for Kubernetes upstream\")?;\n            client_builder = client_builder.identity(identity);\n        }\n    }\n\n    Ok(client_builder)\n}\n\n// Helper function to validate client certificate against database\npub async fn validate_client_certificate(\n    cert_der: &[u8],\n    services: &Services,\n) -> anyhow::Result<Option<AuthStateUserInfo>> {\n    // Convert DER to PEM format for comparison\n    let cert_pem = der_to_pem(cert_der)?;\n\n    let db = services.db.lock().await;\n\n    // Check if certificate is revoked (by serial number)\n    let cert = deserialize_certificate(&cert_pem)?;\n    let serial_b64 = serialize_certificate_serial(&cert);\n    if CertificateRevocation::Entity::find()\n        .filter(CertificateRevocation::Column::SerialNumberBase64.eq(&serial_b64))\n        .one(&*db)\n        .await?\n        .is_some()\n    {\n        warn!(serial = %serial_b64, \"Client certificate is revoked\");\n        return Ok(None);\n    }\n\n    // Find all certificate credentials and match against the provided certificate\n    let cert_credentials = CertificateCredential::Entity::find()\n        .find_with_related(warpgate_db_entities::User::Entity)\n        .all(&*db)\n        .await?;\n\n    for (cert_credential, users) in cert_credentials {\n        if let Some(user) = users.into_iter().next() {\n            // Normalize both certificates for comparison\n            let stored_cert = normalize_certificate_pem(&cert_credential.certificate_pem);\n            let provided_cert = normalize_certificate_pem(&cert_pem);\n\n            if stored_cert == provided_cert {\n                debug!(\n                    user = user.username,\n                    cert_label = cert_credential.label,\n                    \"Client certificate validated for user\"\n                );\n\n                // Update last_used timestamp\n                let mut active_model: CertificateCredential::ActiveModel = cert_credential.into();\n                active_model.last_used = Set(Some(chrono::Utc::now()));\n                if let Err(e) = active_model.update(&*db).await {\n                    warn!(\"Failed to update certificate last_used timestamp: {}\", e);\n                }\n\n                return Ok(Some((&User::try_from(user)?).into()));\n            }\n        }\n    }\n\n    Ok(None)\n}\n\nfn der_to_pem(der_bytes: &[u8]) -> Result<String, anyhow::Error> {\n    use base64::engine::general_purpose;\n    use base64::Engine as _;\n    let cert_b64 = general_purpose::STANDARD.encode(der_bytes);\n    let cert_lines: Vec<String> = cert_b64\n        .chars()\n        .collect::<Vec<char>>()\n        .chunks(64)\n        .map(|chunk| chunk.iter().collect::<String>())\n        .collect();\n\n    Ok(format!(\n        \"-----BEGIN CERTIFICATE-----\\n{}\\n-----END CERTIFICATE-----\",\n        cert_lines.join(\"\\n\")\n    ))\n}\n\nfn normalize_certificate_pem(pem: &str) -> String {\n    pem.lines()\n        .filter(|line| !line.starts_with(\"-----\"))\n        .collect::<Vec<&str>>()\n        .join(\"\")\n        .chars()\n        .filter(|c| !c.is_whitespace())\n        .collect()\n}\n"
  },
  {
    "path": "warpgate-protocol-kubernetes/src/server/client_certs.rs",
    "content": "use std::sync::Arc;\n\nuse base64::{self, Engine};\nuse poem::listener::Acceptor;\nuse poem::web::{LocalAddr, RemoteAddr};\nuse poem::Addr;\nuse rustls::pki_types::{CertificateDer, UnixTime};\nuse rustls::server::danger::{ClientCertVerified, ClientCertVerifier};\nuse rustls::{DigitallySignedStruct, ServerConfig, SignatureScheme};\nuse tokio_rustls::server::TlsStream;\nuse tracing::{debug, warn};\n\n/// Custom client certificate verifier that accepts any client certificate\n#[derive(Debug)]\npub struct AcceptAnyClientCert;\n\nimpl ClientCertVerifier for AcceptAnyClientCert {\n    fn offer_client_auth(&self) -> bool {\n        true\n    }\n\n    fn client_auth_mandatory(&self) -> bool {\n        false\n    }\n\n    fn verify_client_cert(\n        &self,\n        _end_entity: &CertificateDer<'_>,\n        _intermediates: &[CertificateDer<'_>],\n        _now: UnixTime,\n    ) -> Result<ClientCertVerified, rustls::Error> {\n        // Accept any client certificate - we'll extract and validate it later\n        debug!(\"Client certificate received, accepting for later validation\");\n        Ok(ClientCertVerified::assertion())\n    }\n\n    fn verify_tls12_signature(\n        &self,\n        _message: &[u8],\n        _cert: &CertificateDer<'_>,\n        _dss: &DigitallySignedStruct,\n    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {\n        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())\n    }\n\n    fn verify_tls13_signature(\n        &self,\n        _message: &[u8],\n        _cert: &CertificateDer<'_>,\n        _dss: &DigitallySignedStruct,\n    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {\n        Ok(rustls::client::danger::HandshakeSignatureValid::assertion())\n    }\n\n    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {\n        vec![\n            SignatureScheme::RSA_PKCS1_SHA1,\n            SignatureScheme::RSA_PKCS1_SHA256,\n            SignatureScheme::RSA_PKCS1_SHA384,\n            SignatureScheme::RSA_PKCS1_SHA512,\n            SignatureScheme::ECDSA_NISTP256_SHA256,\n            SignatureScheme::ECDSA_NISTP384_SHA384,\n            SignatureScheme::ECDSA_NISTP521_SHA512,\n            SignatureScheme::RSA_PSS_SHA256,\n            SignatureScheme::RSA_PSS_SHA384,\n            SignatureScheme::RSA_PSS_SHA512,\n            SignatureScheme::ED25519,\n            SignatureScheme::ED448,\n        ]\n    }\n\n    fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {\n        &[]\n    }\n}\n\n/// Custom TLS acceptor that captures client certificates and embeds them in remote_addr\npub struct CertificateCapturingAcceptor<T> {\n    inner: T,\n    tls_acceptor: tokio_rustls::TlsAcceptor,\n}\n\nimpl<T> CertificateCapturingAcceptor<T> {\n    pub fn new(inner: T, server_config: ServerConfig) -> Self {\n        Self {\n            inner,\n            tls_acceptor: tokio_rustls::TlsAcceptor::from(Arc::new(server_config)),\n        }\n    }\n}\n\nimpl<T> Acceptor for CertificateCapturingAcceptor<T>\nwhere\n    T: Acceptor,\n{\n    type Io = TlsStream<T::Io>;\n\n    fn local_addr(&self) -> Vec<LocalAddr> {\n        self.inner.local_addr()\n    }\n\n    async fn accept(\n        &mut self,\n    ) -> std::io::Result<(Self::Io, LocalAddr, RemoteAddr, http::uri::Scheme)> {\n        let (stream, local_addr, remote_addr, _) = self.inner.accept().await?;\n\n        // Perform TLS handshake\n        let tls_stream = self.tls_acceptor.accept(stream).await?;\n\n        // Extract client certificate from the TLS connection\n        let enhanced_remote_addr = if let Some(cert_der) = extract_peer_certificates(&tls_stream) {\n            // Serialize certificate as base64 and embed in remote_addr\n            let cert_b64 = base64::engine::general_purpose::STANDARD.encode(&cert_der);\n            let original_remote_addr_str = match &remote_addr.0 {\n                Addr::SocketAddr(addr) => addr.to_string(),\n                Addr::Unix(_) => remote_addr.to_string(),\n                Addr::Custom(_, _) => \"\".into(),\n            };\n            RemoteAddr(Addr::Custom(\n                \"captured-cert\",\n                format!(\"{original_remote_addr_str}|cert:{cert_b64}\").into(),\n            ))\n        } else {\n            remote_addr\n        };\n\n        Ok((\n            tls_stream,\n            local_addr,\n            enhanced_remote_addr,\n            http::uri::Scheme::HTTPS,\n        ))\n    }\n}\n\n/// Extract peer certificates from the TLS stream\nfn extract_peer_certificates<T>(tls_stream: &TlsStream<T>) -> Option<Vec<u8>> {\n    // Get the TLS connection info\n    let (_, tls_conn) = tls_stream.get_ref();\n\n    // Extract peer certificates - this gives us the certificate chain\n    if let Some(peer_certs) = tls_conn.peer_certificates() {\n        if let Some(end_entity_cert) = peer_certs.first() {\n            debug!(\"Extracted client certificate from TLS stream\");\n            return Some(end_entity_cert.as_ref().to_vec());\n        }\n    }\n\n    debug!(\"No client certificate found in TLS stream\");\n    None\n}\n\n/// Certificate data extracted from client TLS connection\n#[derive(Debug, Clone)]\npub struct ClientCertificate {\n    pub der_bytes: Vec<u8>,\n}\n\n/// Middleware that extracts client certificates from enhanced remote_addr and stores them in request extensions\npub struct CertificateExtractorMiddleware;\n\nimpl<E> poem::Middleware<E> for CertificateExtractorMiddleware\nwhere\n    E: poem::Endpoint,\n{\n    type Output = CertificateExtractorEndpoint<E>;\n\n    fn transform(&self, ep: E) -> Self::Output {\n        CertificateExtractorEndpoint { inner: ep }\n    }\n}\n\n// Extracts client certificates stored in the request by [CertificateCapturingAcceptor]\npub struct CertificateExtractorEndpoint<E> {\n    inner: E,\n}\n\nimpl<E> poem::Endpoint for CertificateExtractorEndpoint<E>\nwhere\n    E: poem::Endpoint,\n{\n    type Output = E::Output;\n    async fn call(&self, mut req: poem::Request) -> poem::Result<Self::Output> {\n        // Extract certificate from enhanced remote_addr if present\n        if let RemoteAddr(Addr::Custom(\"captured-cert\", value)) = req.remote_addr() {\n            if let Some(cert_part) = value.split(\"|cert:\").nth(1) {\n                // Decode the base64 certificate\n                match base64::engine::general_purpose::STANDARD.decode(cert_part) {\n                    Ok(cert_der) => {\n                        debug!(\n                        \"Middleware: Successfully extracted client certificate from remote_addr\"\n                    );\n\n                        let client_cert = ClientCertificate {\n                            der_bytes: cert_der,\n                        };\n\n                        // Store certificate in request extensions for later access\n                        req.extensions_mut().insert(client_cert);\n                        debug!(\"Middleware: Client certificate stored in request extensions\");\n                    }\n                    Err(e) => {\n                        warn!(\n                            \"Middleware: Failed to decode client certificate from remote_addr: {}\",\n                            e\n                        );\n                    }\n                }\n            }\n        } else {\n            debug!(\"Middleware: No client certificate found in remote_addr\");\n        }\n\n        // Continue with the request\n        self.inner.call(req).await\n    }\n}\n\n/// Helper trait to easily extract client certificate from request\npub trait RequestCertificateExt {\n    /// Get the client certificate from request extensions, if present\n    fn client_certificate(&self) -> Option<&ClientCertificate>;\n}\n\nimpl RequestCertificateExt for poem::Request {\n    fn client_certificate(&self) -> Option<&ClientCertificate> {\n        self.extensions().get::<ClientCertificate>()\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-kubernetes/src/server/handlers.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::Arc;\n\nuse anyhow::{bail, Context, Result};\nuse futures::{StreamExt, TryStreamExt};\nuse poem::web::websocket::{WebSocket, WebSocketStream};\nuse poem::web::{Data, Path};\nuse poem::{handler, Body, IntoResponse, Request, Response};\nuse reqwest_websocket::Upgrade;\nuse serde::Deserialize;\nuse tokio::sync::{mpsc, Mutex};\nuse tokio_tungstenite::tungstenite;\nuse tracing::*;\nuse url::Url;\nuse warpgate_common::auth::AuthStateUserInfo;\nuse warpgate_common::helpers::websocket::pump_websocket;\nuse warpgate_common::http_headers::DONT_FORWARD_HEADERS;\nuse warpgate_common::{SessionId, TargetKubernetesOptions, TargetOptions, WarpgateError};\nuse warpgate_common_http::auth::UnauthenticatedRequestContext;\nuse warpgate_common_http::logging::{\n    get_client_ip, log_request_error, log_request_result, span_for_request,\n};\nuse warpgate_core::recordings::{TerminalRecorder, TerminalRecordingStreamId};\nuse warpgate_core::Services;\n\nuse crate::correlator::RequestCorrelator;\nuse crate::recording::{deduce_exec_recording_metadata, start_recording_api, start_recording_exec};\nuse crate::server::auth::{authenticate_and_get_target, create_authenticated_client};\n\nfn construct_target_url(\n    req: &Request,\n    path: &str,\n    k8s_options: &TargetKubernetesOptions,\n) -> Result<Url> {\n    let api_path = format!(\"/{}\", path);\n\n    let query = req.uri().query().unwrap_or(\"\");\n\n    Ok(Url::parse(&if query.is_empty() {\n        format!(\"{}{}\", k8s_options.cluster_url, api_path)\n    } else {\n        format!(\"{}{}?{}\", k8s_options.cluster_url, api_path, query)\n    })?)\n}\n\n#[handler]\n#[allow(clippy::too_many_arguments)]\npub async fn handle_api_request(\n    ws: Option<WebSocket>,\n    req: &Request,\n    Path((target_name, path)): Path<(String, String)>,\n    body: Body,\n    correlator: Data<&Arc<Mutex<RequestCorrelator>>>,\n    ctx: Data<&UnauthenticatedRequestContext>,\n) -> Result<Response, poem::Error> {\n    debug!(\n        target_name = target_name,\n        path_param = ?path,\n        full_uri = %req.uri(),\n        \"Handling Kubernetes API request\"\n    );\n\n    let (user_info, target) = authenticate_and_get_target(req, &target_name, &ctx.services).await?;\n\n    let TargetOptions::Kubernetes(k8s_options) = &target.options else {\n        return Err(poem::Error::from_string(\n            \"Invalid target type\",\n            poem::http::StatusCode::BAD_REQUEST,\n        ));\n    };\n\n    let handle = correlator\n        .lock()\n        .await\n        .session_for_request(req, &user_info, &target.name)\n        .await?;\n\n    let (session_id, log_span) = {\n        let handle: tokio::sync::MutexGuard<'_, warpgate_core::WarpgateServerHandle> =\n            handle.lock().await;\n        handle.set_target(&target).await?;\n        handle.set_user_info(user_info.clone()).await?;\n        (\n            handle.id(),\n            span_for_request(req, &ctx.services, Some(&*handle)).await?,\n        )\n    };\n\n    async {\n        let response = if let Some(ws) = ws {\n            _handle_websocket_request_inner(\n                ws,\n                req,\n                k8s_options,\n                &path,\n                user_info,\n                session_id,\n                &ctx.services,\n            )\n            .await\n            .map(IntoResponse::into_response)\n        } else {\n            _handle_normal_request_inner(\n                req,\n                body,\n                k8s_options,\n                &path,\n                user_info,\n                session_id,\n                &ctx.services,\n            )\n            .await\n            .map(IntoResponse::into_response)\n            .context(\"handling Kubernetes API request\")\n        };\n\n        let client_ip = get_client_ip(req, &ctx.services).await;\n        let response = response.inspect_err(|e| {\n            log_request_error(req.method(), req.original_uri(), client_ip.as_deref(), e);\n        })?;\n\n        log_request_result(\n            req.method(),\n            req.original_uri(),\n            client_ip.as_deref(),\n            &response.status(),\n        );\n\n        Ok(response)\n    }\n    .instrument(log_span)\n    .await\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn _handle_normal_request_inner(\n    req: &Request,\n    body: Body,\n    k8s_options: &TargetKubernetesOptions,\n    path: &str,\n    user_info: AuthStateUserInfo,\n    session_id: SessionId,\n    services: &Services,\n) -> Result<Response, WarpgateError> {\n    let client =\n        create_authenticated_client(k8s_options, &Some(user_info.username.clone()), services)?\n            .build()\n            .context(\"building reqwest client\")?;\n\n    debug!(\n        \"Target Kubernetes options: cluster_url={}, auth={:?}\",\n        k8s_options.cluster_url,\n        match &k8s_options.auth {\n            warpgate_common::KubernetesTargetAuth::Token(_) => \"Token\",\n            warpgate_common::KubernetesTargetAuth::Certificate(_) => \"Certificate\",\n        }\n    );\n\n    let method = req.method().as_str();\n    // Construct the full URL to the Kubernetes API server (without target prefix)\n    let full_url =\n        construct_target_url(req, path, k8s_options).context(\"constructing target URL\")?;\n\n    // Extract headers\n    let mut headers = HashMap::new();\n    for (name, value) in req.headers() {\n        // Still forward Accept-Encoding to allow for chunked encoding\n        if DONT_FORWARD_HEADERS.contains(name) && name != http::header::ACCEPT_ENCODING {\n            continue;\n        }\n        if let Ok(mut value_str) = value.to_str().map(|s| s.to_string()) {\n            if name == http::header::ACCEPT {\n                let values = value\n                    .to_str()\n                    .unwrap_or_default()\n                    .split(',')\n                    .map(|s| s.trim())\n                    .filter(|s| *s != \"application/vnd.kubernetes.protobuf\") // cannot parse protobuf yet\n                    .collect::<Vec<_>>();\n                value_str = values.join(\", \");\n            }\n            headers.insert(name.to_string(), value_str.to_string());\n        }\n    }\n\n    // Get request body\n    let body_bytes = body.into_bytes().await.context(\"reading request body\")?;\n\n    // Record the request if recording is enabled\n    let mut recorder_opt = {\n        let enabled = {\n            let config = services.config.lock().await;\n            config.store.recordings.enable\n        };\n        if enabled {\n            match start_recording_api(&session_id, &services.recordings).await {\n                Ok(recorder) => Some(recorder),\n                Err(e) => {\n                    warn!(\"Failed to start recording: {}\", e);\n                    None\n                }\n            }\n        } else {\n            None\n        }\n    };\n\n    // Forward request to Kubernetes API\n    let mut request_builder = client.request(\n        http::Method::from_bytes(method.as_bytes()).context(\"request method\")?,\n        full_url.clone(),\n    );\n\n    // Add headers (excluding authorization, host, and content-length as they'll be set by reqwest)\n    let mut upstream_headers = HashMap::new();\n    for (name, value) in &headers {\n        let header_name_lower = name.to_lowercase();\n        if ![\n            \"host\",\n            \"content-length\",\n            \"connection\",\n            \"transfer-encoding\",\n            \"authorization\",\n        ]\n        .contains(&header_name_lower.as_str())\n        {\n            if let (Ok(header_name), Ok(header_value)) = (\n                http::HeaderName::from_bytes(name.as_bytes()),\n                http::HeaderValue::from_str(value),\n            ) {\n                request_builder = request_builder.header(header_name, header_value);\n                upstream_headers.insert(name.clone(), value.clone());\n            }\n        } else {\n            debug!(header = name, \"Filtering out header from upstream request\");\n        }\n    }\n\n    debug!(\n        filtered_headers = ?upstream_headers,\n        \"Headers being sent to upstream Kubernetes API\"\n    );\n\n    if !body_bytes.is_empty() {\n        request_builder = request_builder.body(body_bytes.to_vec());\n    }\n\n    // Debug logging for upstream request\n    debug!(\n        method = method,\n        url = %full_url,\n        headers = ?headers,\n        body_size = body_bytes.len(),\n        \"Sending request to upstream Kubernetes API\"\n    );\n\n    let response = request_builder.send().await?;\n\n    let status = response.status();\n    let response_headers = response.headers().clone();\n\n    debug!(\n        method = method,\n        url = %full_url,\n        status = %status,\n        response_headers = ?response_headers,\n        \"Received response from upstream Kubernetes API\"\n    );\n\n    let (response_body, body_for_recording) = {\n        // k8s uses streaming chunked responses for watch API\n        let transfer_encoding = response_headers\n            .get(poem::http::header::TRANSFER_ENCODING)\n            .and_then(|v| v.to_str().ok())\n            .unwrap_or_default()\n            .to_lowercase();\n\n        let is_watch = req\n            .uri()\n            .query()\n            .map(|q| q.split('&').any(|p| p.starts_with(\"watch=true\")))\n            .unwrap_or(false);\n\n        if transfer_encoding == \"chunked\" || is_watch {\n            (\n                Body::from_bytes_stream(response.bytes_stream().map_err(std::io::Error::other)),\n                None,\n            )\n        } else {\n            let bytes = response\n                .bytes()\n                .await\n                .context(\"reading kubernetes response\")?;\n\n            (Body::from_bytes(bytes.clone()), Some(bytes.to_vec()))\n        }\n    };\n\n    // Record the response\n    if let Some(ref mut recorder) = recorder_opt {\n        if let Err(e) = recorder\n            .record_response(\n                method,\n                full_url.as_ref(),\n                headers,\n                &body_bytes,\n                status.as_u16(),\n                body_for_recording.unwrap_or_default().as_ref(),\n            )\n            .await\n        {\n            warn!(\"Failed to record Kubernetes response: {}\", e);\n        }\n    }\n\n    let mut poem_response = Response::builder().status(status);\n\n    // Copy response headers\n    for (name, value) in response_headers.iter() {\n        if let Ok(poem_name) = poem::http::HeaderName::from_bytes(name.as_str().as_bytes()) {\n            if let Ok(poem_value) = poem::http::HeaderValue::from_bytes(value.as_bytes()) {\n                poem_response = poem_response.header(poem_name, poem_value);\n            }\n        }\n    }\n\n    Ok(poem_response.body(response_body))\n}\n\nasync fn run_websocket_recording(mut recorder: TerminalRecorder, mut rx: mpsc::Receiver<Vec<u8>>) {\n    while let Some(data) = rx.recv().await {\n        if data.is_empty() {\n            continue;\n        }\n        let msg_type = data[0];\n        let data = data[1..].to_vec();\n\n        let result = match msg_type {\n            0..2 => {\n                recorder\n                    .write(\n                        TerminalRecordingStreamId::from_usual_fd_number(msg_type)\n                            .unwrap_or_default(),\n                        &data,\n                    )\n                    .await\n            }\n            4 => {\n                #[derive(Deserialize)]\n                struct ResizeData {\n                    #[serde(rename = \"Width\")]\n                    width: u32,\n                    #[serde(rename = \"Height\")]\n                    height: u32,\n                }\n                if let Ok(resize_data) = serde_json::from_slice::<ResizeData>(&data) {\n                    recorder\n                        .write_pty_resize(resize_data.width, resize_data.height)\n                        .await\n                } else {\n                    continue;\n                }\n            }\n            _ => continue,\n        };\n        if let Err(e) = result {\n            error!(\"Failed to write recording item: {}\", e);\n        }\n    }\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn _handle_websocket_request_inner(\n    ws: WebSocket,\n    req: &Request,\n    k8s_options: &TargetKubernetesOptions,\n    path: &str,\n    user_info: AuthStateUserInfo,\n    session_id: SessionId,\n    services: &Services,\n) -> anyhow::Result<impl IntoResponse> {\n    let mut full_url = construct_target_url(req, path, k8s_options)?;\n    if full_url.scheme() == \"https\" {\n        let _ = full_url.set_scheme(\"wss\");\n    } else {\n        let _ = full_url.set_scheme(\"ws\");\n    }\n\n    let client =\n        create_authenticated_client(k8s_options, &Some(user_info.username.clone()), services)?\n            .http1_only()\n            .build()?;\n\n    let (recorder_tx, recorder_rx) = mpsc::channel::<Vec<u8>>(1000);\n    {\n        let enabled = {\n            let config = services.config.lock().await;\n            config.store.recordings.enable\n        };\n        if enabled {\n            match start_recording_exec(\n                &session_id,\n                &services.recordings,\n                deduce_exec_recording_metadata(&full_url),\n            )\n            .await\n            {\n                Err(e) => {\n                    error!(\"Failed to start recording: {}\", e);\n                }\n                Ok(recorder) => {\n                    tokio::spawn(run_websocket_recording(recorder, recorder_rx));\n                }\n            }\n        }\n    };\n\n    let ws_protocol = req\n        .headers()\n        .get(\"sec-websocket-protocol\")\n        .and_then(|h| h.to_str().ok())\n        .context(\"missing Sec-Websocket-Protocol request header\")?\n        .to_string();\n\n    let ws_handler_inner = async move |socket: WebSocketStream| {\n        let client_response = client\n            .get(full_url.clone())\n            .upgrade()\n            .protocols(vec![ws_protocol])\n            .send()\n            .await\n            .context(\"sending websocket request to Kubernetes API\")?;\n\n        let status = client_response.status();\n        if status != http::StatusCode::SWITCHING_PROTOCOLS {\n            let client_response = client_response.into_inner();\n            let body = client_response.text().await?;\n            bail!(\"Unexpected websocket response status from Kubernetes API: {status}: {body}\");\n        }\n\n        let client_socket = client_response\n            .into_websocket()\n            .await\n            .context(\"negotiating websocket connection with Kubernetes\")?;\n\n        let (client_sink, client_source) = client_socket.split();\n\n        let (server_sink, server_source) = socket.split();\n        let server_to_client = {\n            let recorder_tx = recorder_tx.clone();\n            tokio::spawn(pump_websocket(server_source, client_sink, move |msg| {\n                let recorder_tx = recorder_tx.clone();\n                async move {\n                    tracing::debug!(\"Server: {:?}\", msg);\n                    if let tungstenite::Message::Binary(data) = &msg {\n                        let _ = recorder_tx.send(data.to_vec()).await;\n                    }\n                    anyhow::Ok(msg)\n                }\n            }))\n        };\n\n        let client_to_server =\n            tokio::spawn(pump_websocket(client_source, server_sink, move |msg| {\n                let recorder_tx = recorder_tx.clone();\n                async move {\n                    tracing::debug!(\"Client: {:?}\", msg);\n                    if let tungstenite::Message::Binary(data) = &msg {\n                        let _ = recorder_tx.send(data.to_vec()).await;\n                    }\n                    anyhow::Ok(msg)\n                }\n            }));\n\n        server_to_client.await??;\n        client_to_server.await??;\n        debug!(\"Closing Websocket stream\");\n        Ok::<(), anyhow::Error>(())\n    };\n\n    Ok(ws\n        .protocols(vec![\n            \"channel.k8s.io\",\n            \"v2.channel.k8s.io\",\n            \"v3.channel.k8s.io\",\n            \"v4.channel.k8s.io\",\n            \"v5.channel.k8s.io\",\n        ])\n        .on_upgrade(|socket| async move {\n            ws_handler_inner(socket).await.inspect_err(|e| {\n                error!(\"Websocket handling error: {e:?}\");\n            })?;\n            Ok::<(), anyhow::Error>(())\n        })\n        .into_response())\n}\n"
  },
  {
    "path": "warpgate-protocol-kubernetes/src/server/mod.rs",
    "content": "use std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse poem::listener::Listener;\nuse poem::{EndpointExt, Route, Server};\nuse rustls::ServerConfig;\nuse tracing::*;\nuse warpgate_common::ListenEndpoint;\nuse warpgate_common_http::auth::UnauthenticatedRequestContext;\nuse warpgate_core::Services;\nuse warpgate_tls::{\n    SingleCertResolver, TlsCertificateAndPrivateKey, TlsCertificateBundle, TlsPrivateKey,\n};\n\nuse crate::correlator::RequestCorrelator;\nuse crate::server::client_certs::{AcceptAnyClientCert, CertificateCapturingAcceptor};\nuse crate::server::handlers::handle_api_request;\n\nmod auth;\nmod client_certs;\nmod handlers;\n\nuse client_certs::CertificateExtractorMiddleware;\n\npub async fn run_server(services: Services, address: ListenEndpoint) -> Result<()> {\n    let correlator = RequestCorrelator::new(&services);\n\n    let app = Route::new()\n        .at(\"/:target_name/*path\", handle_api_request)\n        .with(poem::middleware::Cors::new())\n        .with(CertificateExtractorMiddleware)\n        .data(UnauthenticatedRequestContext {\n            services: services.clone(),\n        })\n        .data(correlator);\n\n    info!(?address, \"Kubernetes protocol listening\");\n\n    let certificate_and_key = {\n        let config = services.config.lock().await;\n        let certificate_path = services\n            .global_params\n            .paths_relative_to()\n            .join(&config.store.kubernetes.certificate);\n        let key_path = services\n            .global_params\n            .paths_relative_to()\n            .join(&config.store.kubernetes.key);\n\n        TlsCertificateAndPrivateKey {\n            certificate: TlsCertificateBundle::from_file(&certificate_path)\n                .await\n                .with_context(|| {\n                    format!(\n                        \"reading TLS certificate from '{}'\",\n                        certificate_path.display()\n                    )\n                })?,\n            private_key: TlsPrivateKey::from_file(&key_path).await.with_context(|| {\n                format!(\"reading TLS private key from '{}'\", key_path.display())\n            })?,\n        }\n    };\n\n    // Create TLS configuration with client certificate verification\n    let tls_config = ServerConfig::builder_with_provider(Arc::new(\n        rustls::crypto::aws_lc_rs::default_provider(),\n    ))\n    .with_safe_default_protocol_versions()\n    .map_err(|e| anyhow::anyhow!(\"Failed to configure TLS protocol versions: {}\", e))?\n    .with_client_cert_verifier(Arc::new(AcceptAnyClientCert))\n    .with_cert_resolver(Arc::new(SingleCertResolver::new(\n        certificate_and_key.clone(),\n    )));\n\n    let tcp_acceptor = address.poem_listener().await?.into_acceptor().await?;\n    let cert_capturing_acceptor = CertificateCapturingAcceptor::new(tcp_acceptor, tls_config);\n\n    Server::new_with_acceptor(cert_capturing_acceptor)\n        .run(app)\n        .await\n        .context(\"Kubernetes server error\")?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "warpgate-protocol-kubernetes/src/session_handle.rs",
    "content": "use warpgate_core::SessionHandle;\n\npub struct KubernetesSessionHandle;\n\nimpl SessionHandle for KubernetesSessionHandle {\n    fn close(&mut self) {\n        // no-op\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-mysql/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-protocol-mysql\"\nversion = \"0.22.0\"\n\n[dependencies]\nwarpgate-common = { version = \"*\", path = \"../warpgate-common\", default-features = false }\nwarpgate-tls = { version = \"*\", path = \"../warpgate-tls\", default-features = false }\nwarpgate-core = { version = \"*\", path = \"../warpgate-core\", default-features = false }\nwarpgate-db-entities = { version = \"*\", path = \"../warpgate-db-entities\", default-features = false }\nwarpgate-database-protocols = { version = \"*\", path = \"../warpgate-database-protocols\", default-features = false }\nanyhow.workspace = true\nasync-trait = { version = \"0.1\", default-features = false }\nfutures.workspace = true\ntokio.workspace = true\ntracing.workspace = true\nuuid.workspace = true\nbytes.workspace = true\nmysql_common = { version = \"0.34\", default-features = false }\nflate2 = { version = \"1\", features = [\"zlib\"], default-features = false }\nrand.workspace = true\nsha1 = { version = \"0.10\", default-features = false }\npassword-hash.workspace = true\nrustls.workspace = true\ntokio-rustls.workspace = true\nthiserror.workspace = true\nwebpki = { version = \"0.22\", default-features = false }\nonce_cell = { version = \"1.17\", default-features = false }\n"
  },
  {
    "path": "warpgate-protocol-mysql/src/client.rs",
    "content": "use std::sync::Arc;\n\nuse bytes::BytesMut;\nuse tokio::net::TcpStream;\nuse tracing::*;\nuse warpgate_common::TargetMySqlOptions;\nuse warpgate_database_protocols::io::Decode;\nuse warpgate_database_protocols::mysql::protocol::auth::AuthPlugin;\nuse warpgate_database_protocols::mysql::protocol::connect::{\n    Handshake, HandshakeResponse, SslRequest,\n};\nuse warpgate_database_protocols::mysql::protocol::response::ErrPacket;\nuse warpgate_database_protocols::mysql::protocol::Capabilities;\nuse warpgate_tls::{configure_tls_connector, TlsMode};\n\nuse crate::common::compute_auth_challenge_response;\nuse crate::error::MySqlError;\nuse crate::stream::MySqlStream;\n\npub struct MySqlClient {\n    pub stream: MySqlStream<TcpStream, tokio_rustls::client::TlsStream<TcpStream>>,\n    pub _capabilities: Capabilities,\n}\n\npub struct ConnectionOptions {\n    pub collation: u8,\n    pub database: Option<String>,\n    pub max_packet_size: u32,\n    pub capabilities: Capabilities,\n}\n\nimpl Default for ConnectionOptions {\n    fn default() -> Self {\n        ConnectionOptions {\n            collation: 33,\n            database: None,\n            max_packet_size: 0xffff_ffff,\n            capabilities: Capabilities::PROTOCOL_41\n                | Capabilities::PLUGIN_AUTH\n                | Capabilities::FOUND_ROWS\n                | Capabilities::LONG_FLAG\n                | Capabilities::NO_SCHEMA\n                | Capabilities::PLUGIN_AUTH_LENENC_DATA\n                | Capabilities::CONNECT_WITH_DB\n                | Capabilities::SESSION_TRACK\n                | Capabilities::IGNORE_SPACE\n                | Capabilities::INTERACTIVE\n                | Capabilities::TRANSACTIONS\n                | Capabilities::DEPRECATE_EOF\n                | Capabilities::SECURE_CONNECTION\n                | Capabilities::SSL,\n        }\n    }\n}\n\nimpl MySqlClient {\n    pub async fn connect(\n        target: &TargetMySqlOptions,\n        mut options: ConnectionOptions,\n    ) -> Result<Self, MySqlError> {\n        let stream = TcpStream::connect((target.host.clone(), target.port)).await?;\n        stream.set_nodelay(true)?;\n\n        let mut stream = MySqlStream::new(stream);\n\n        options.capabilities.remove(Capabilities::SSL);\n        if target.tls.mode != TlsMode::Disabled {\n            options.capabilities |= Capabilities::SSL;\n        }\n\n        let Some(payload) = stream.recv().await? else {\n            return Err(MySqlError::Eof);\n        };\n        let handshake = Handshake::decode(payload)?;\n\n        options.capabilities &= handshake.server_capabilities;\n        if target.tls.mode == TlsMode::Required && !options.capabilities.contains(Capabilities::SSL)\n        {\n            return Err(MySqlError::TlsNotSupported);\n        }\n\n        info!(capabilities=?options.capabilities, \"Target handshake\");\n\n        if options.capabilities.contains(Capabilities::SSL) && target.tls.mode != TlsMode::Disabled\n        {\n            let accept_invalid_certs = !target.tls.verify;\n            let accept_invalid_hostname = false; // ca + hostname verification\n            let client_config = Arc::new(\n                configure_tls_connector(accept_invalid_certs, accept_invalid_hostname, None)\n                    .await?,\n            );\n            let req = SslRequest {\n                collation: options.collation,\n                max_packet_size: options.max_packet_size,\n            };\n            stream.push(&req, options.capabilities)?;\n            stream.flush().await?;\n            stream = stream\n                .upgrade((\n                    target\n                        .host\n                        .clone()\n                        .try_into()\n                        .map_err(|_| MySqlError::InvalidDomainName)?,\n                    client_config,\n                ))\n                .await?;\n            info!(\"Target connection upgraded to TLS\");\n        }\n\n        let mut response = HandshakeResponse {\n            auth_plugin: None,\n            auth_response: None,\n            collation: options.collation,\n            database: options.database,\n            max_packet_size: options.max_packet_size,\n            username: target.username.clone(),\n        };\n\n        if handshake.auth_plugin == Some(AuthPlugin::MySqlNativePassword) {\n            let scramble_bytes = [\n                &handshake.auth_plugin_data.first_ref()[..],\n                &handshake.auth_plugin_data.last_ref()[..],\n            ]\n            .concat();\n            match scramble_bytes.try_into() as Result<[u8; 20], Vec<u8>> {\n                Err(scramble_bytes) => {\n                    warn!(\"Invalid scramble length ({})\", scramble_bytes.len());\n                }\n                Ok(scramble) => {\n                    response.auth_plugin = Some(AuthPlugin::MySqlNativePassword);\n                    response.auth_response = Some(\n                        BytesMut::from(\n                            compute_auth_challenge_response(\n                                scramble,\n                                target.password.as_deref().unwrap_or(\"\"),\n                            )\n                            .map_err(MySqlError::other)?\n                            .as_bytes(),\n                        )\n                        .freeze(),\n                    );\n                    trace!(response=?response.auth_response, ?scramble, \"auth\");\n                }\n            }\n        }\n\n        stream.push(&response, options.capabilities)?;\n        stream.flush().await?;\n\n        let Some(response) = stream.recv().await? else {\n            return Err(MySqlError::Eof);\n        };\n        if response.first() == Some(&0) || response.first() == Some(&0xfe) {\n            debug!(\"Authorized\");\n        } else if response.first() == Some(&0xff) {\n            let error = ErrPacket::decode_with(response, options.capabilities)?;\n            return Err(MySqlError::ProtocolError(format!(\n                \"handshake failed: {error:?}\"\n            )));\n        } else {\n            return Err(MySqlError::ProtocolError(format!(\n                \"unknown response type {:?}\",\n                response.first()\n            )));\n        }\n\n        stream.reset_sequence_id();\n\n        Ok(Self {\n            stream,\n            _capabilities: options.capabilities,\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-mysql/src/common.rs",
    "content": "use sha1::Digest;\nuse warpgate_common::ProtocolName;\n\npub const PROTOCOL_NAME: ProtocolName = \"MySQL\";\n\npub fn compute_auth_challenge_response(\n    challenge: [u8; 20],\n    password: &str,\n) -> Result<password_hash::Output, password_hash::Error> {\n    password_hash::Output::new(\n        &{\n            let password_sha: [u8; 20] = sha1::Sha1::digest(password).into();\n            let password_sha_sha: [u8; 20] = sha1::Sha1::digest(password_sha).into();\n            let password_seed_2sha_sha: [u8; 20] =\n                sha1::Sha1::digest([challenge, password_sha_sha].concat()).into();\n\n            let mut result = password_sha;\n            result\n                .iter_mut()\n                .zip(password_seed_2sha_sha.iter())\n                .for_each(|(x1, x2)| *x1 ^= *x2);\n            result\n        }[..],\n    )\n}\n"
  },
  {
    "path": "warpgate-protocol-mysql/src/error.rs",
    "content": "use std::error::Error;\n\nuse warpgate_common::WarpgateError;\nuse warpgate_database_protocols::error::Error as SqlxError;\nuse warpgate_tls::{MaybeTlsStreamError, RustlsSetupError};\n\nuse crate::stream::MySqlStreamError;\n\n#[derive(thiserror::Error, Debug)]\npub enum MySqlError {\n    #[error(\"protocol error: {0}\")]\n    ProtocolError(String),\n    #[error(\"sudden disconnection\")]\n    Eof,\n    #[error(\"server doesn't offer TLS\")]\n    TlsNotSupported,\n    #[error(\"client doesn't support TLS\")]\n    TlsNotSupportedByClient,\n    #[error(\"TLS setup failed: {0}\")]\n    TlsSetup(#[from] RustlsSetupError),\n    #[error(\"TLS stream error: {0}\")]\n    Tls(#[from] MaybeTlsStreamError),\n    #[error(\"Invalid domain name\")]\n    InvalidDomainName,\n    #[error(\"sqlx error: {0}\")]\n    Sqlx(#[from] SqlxError),\n    #[error(\"MySQL stream error: {0}\")]\n    MySqlStream(#[from] MySqlStreamError),\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(\"packet decode error: {0}\")]\n    Decode(Box<dyn Error + Send + Sync>),\n    #[error(transparent)]\n    Warpgate(#[from] WarpgateError),\n    #[error(transparent)]\n    Other(Box<dyn Error + Send + Sync>),\n}\n\nimpl MySqlError {\n    pub fn other<E: Error + Send + Sync + 'static>(err: E) -> Self {\n        Self::Other(Box::new(err))\n    }\n\n    pub fn decode(err: SqlxError) -> Self {\n        match err {\n            SqlxError::Decode(err) => Self::Decode(err),\n            _ => Self::Sqlx(err),\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-mysql/src/lib.rs",
    "content": "mod client;\nmod common;\nmod error;\nmod session;\nmod session_handle;\nmod stream;\nuse std::fmt::Debug;\nuse std::sync::Arc;\n\nuse anyhow::{Context, Result};\nuse futures::TryStreamExt;\nuse rustls::server::NoClientAuth;\nuse rustls::ServerConfig;\nuse tracing::*;\nuse warpgate_common::ListenEndpoint;\nuse warpgate_core::{ProtocolServer, Services, SessionStateInit, State};\nuse warpgate_tls::{\n    ResolveServerCert, TlsCertificateAndPrivateKey, TlsCertificateBundle, TlsPrivateKey,\n};\n\nuse crate::session::MySqlSession;\nuse crate::session_handle::MySqlSessionHandle;\n\npub struct MySQLProtocolServer {\n    services: Services,\n}\n\nimpl MySQLProtocolServer {\n    pub async fn new(services: &Services) -> Result<Self> {\n        Ok(MySQLProtocolServer {\n            services: services.clone(),\n        })\n    }\n}\n\nimpl ProtocolServer for MySQLProtocolServer {\n    async fn run(self, address: ListenEndpoint) -> Result<()> {\n        let certificate_and_key = {\n            let config = self.services.config.lock().await;\n            let paths_rel_to = self.services.global_params.paths_relative_to();\n            let certificate_path = paths_rel_to.join(&config.store.mysql.certificate);\n            let key_path = paths_rel_to.join(&config.store.mysql.key);\n\n            TlsCertificateAndPrivateKey {\n                certificate: TlsCertificateBundle::from_file(&certificate_path)\n                    .await\n                    .with_context(|| {\n                        format!(\"reading SSL private key from '{}'\", key_path.display())\n                    })?,\n                private_key: TlsPrivateKey::from_file(&key_path).await.with_context(|| {\n                    format!(\n                        \"reading SSL certificate from '{}'\",\n                        certificate_path.display()\n                    )\n                })?,\n            }\n        };\n\n        let tls_config = ServerConfig::builder_with_provider(Arc::new(\n            rustls::crypto::aws_lc_rs::default_provider(),\n        ))\n        .with_safe_default_protocol_versions()?\n        .with_client_cert_verifier(Arc::new(NoClientAuth))\n        .with_cert_resolver(Arc::new(ResolveServerCert(Arc::new(\n            certificate_and_key.into(),\n        ))));\n\n        let mut listener = address.tcp_accept_stream().await?;\n\n        loop {\n            let Some(stream) = listener.try_next().await.context(\"accepting connection\")? else {\n                return Ok(());\n            };\n            let remote_address = stream.peer_addr().context(\"getting peer address\")?;\n\n            stream.set_nodelay(true)?;\n\n            let tls_config = tls_config.clone();\n            let services = self.services.clone();\n            tokio::spawn(async move {\n                let (session_handle, mut abort_rx) = MySqlSessionHandle::new();\n\n                let server_handle = State::register_session(\n                    &services.state,\n                    &crate::common::PROTOCOL_NAME,\n                    SessionStateInit {\n                        remote_address: Some(remote_address),\n                        handle: Box::new(session_handle),\n                    },\n                )\n                .await\n                .context(\"registering session\")?;\n\n                let wrapped_stream = server_handle.lock().await.wrap_stream(stream).await?;\n\n                let session = MySqlSession::new(\n                    server_handle,\n                    services,\n                    wrapped_stream,\n                    tls_config,\n                    remote_address,\n                )\n                .await;\n                let span = session.make_logging_span();\n                tokio::select! {\n                    result = session.run().instrument(span) => match result {\n                        Ok(_) => info!(\"Session ended\"),\n                        Err(e) => error!(error=%e, \"Session failed\"),\n                    },\n                    _ = abort_rx.recv() => {\n                        warn!(\"Session aborted by admin\");\n                    },\n                }\n\n                Ok::<(), anyhow::Error>(())\n            });\n        }\n    }\n\n    fn name(&self) -> &'static str {\n        \"MySQL\"\n    }\n}\n\nimpl Debug for MySQLProtocolServer {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"MySQLProtocolServer\").finish()\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-mysql/src/session.rs",
    "content": "use std::net::SocketAddr;\nuse std::ops::Deref;\nuse std::sync::Arc;\n\nuse bytes::{Buf, Bytes, BytesMut};\nuse rand::Rng;\nuse rustls::ServerConfig;\nuse tokio::io::{AsyncRead, AsyncWrite};\nuse tokio::sync::Mutex;\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::auth::{\n    AuthCredential, AuthResult, AuthSelector, AuthStateUserInfo, CredentialKind,\n};\nuse warpgate_common::helpers::rng::get_crypto_rng;\nuse warpgate_common::{Secret, TargetMySqlOptions, TargetOptions};\nuse warpgate_core::{\n    authorize_ticket, consume_ticket, ConfigProvider, Services, WarpgateServerHandle,\n};\nuse warpgate_database_protocols::io::{BufExt, Decode};\nuse warpgate_database_protocols::mysql::protocol::auth::AuthPlugin;\nuse warpgate_database_protocols::mysql::protocol::connect::{\n    AuthSwitchRequest, Handshake, HandshakeResponse,\n};\nuse warpgate_database_protocols::mysql::protocol::response::{ErrPacket, OkPacket, Status};\nuse warpgate_database_protocols::mysql::protocol::text::Query;\nuse warpgate_database_protocols::mysql::protocol::Capabilities;\n\nuse crate::client::{ConnectionOptions, MySqlClient};\nuse crate::error::MySqlError;\nuse crate::stream::MySqlStream;\n\npub struct MySqlSession<S: AsyncRead + AsyncWrite + Send + Unpin> {\n    stream: MySqlStream<S, tokio_rustls::server::TlsStream<S>>,\n    capabilities: Capabilities,\n    challenge: [u8; 20],\n    username: Option<String>,\n    database: Option<String>,\n    tls_config: Arc<ServerConfig>,\n    server_handle: Arc<Mutex<WarpgateServerHandle>>,\n    id: Uuid,\n    services: Services,\n    remote_address: SocketAddr,\n}\n\nimpl<S: AsyncRead + AsyncWrite + Send + Unpin> MySqlSession<S> {\n    pub async fn new(\n        server_handle: Arc<Mutex<WarpgateServerHandle>>,\n        services: Services,\n        stream: S,\n        tls_config: ServerConfig,\n        remote_address: SocketAddr,\n    ) -> Self {\n        let id = server_handle.lock().await.id();\n        Self {\n            services,\n            stream: MySqlStream::new(stream),\n            capabilities: Capabilities::PROTOCOL_41\n                | Capabilities::PLUGIN_AUTH\n                | Capabilities::FOUND_ROWS\n                | Capabilities::LONG_FLAG\n                | Capabilities::NO_SCHEMA\n                | Capabilities::PLUGIN_AUTH_LENENC_DATA\n                | Capabilities::CONNECT_WITH_DB\n                | Capabilities::SESSION_TRACK\n                | Capabilities::IGNORE_SPACE\n                | Capabilities::INTERACTIVE\n                | Capabilities::TRANSACTIONS\n                | Capabilities::DEPRECATE_EOF\n                | Capabilities::SECURE_CONNECTION\n                | Capabilities::SSL,\n            challenge: get_crypto_rng().gen(),\n            tls_config: Arc::new(tls_config),\n            username: None,\n            database: None,\n            server_handle,\n            id,\n            remote_address,\n        }\n    }\n\n    pub fn make_logging_span(&self) -> tracing::Span {\n        let client_ip = self.remote_address.ip().to_string();\n        match self.username {\n            Some(ref username) => {\n                info_span!(\"MySQL\", session=%self.id, session_username=%username, %client_ip)\n            }\n            None => info_span!(\"MySQL\", session=%self.id, %client_ip),\n        }\n    }\n\n    pub async fn run(mut self) -> Result<(), MySqlError> {\n        let mut challenge_1 = BytesMut::from(&self.challenge[..]);\n        let challenge_2 = challenge_1.split_off(8);\n        let challenge_chain = challenge_1.freeze().chain(challenge_2.freeze());\n\n        let handshake = Handshake {\n            protocol_version: 10,\n            server_version: \"8.0.0-Warpgate\".to_owned(),\n            connection_id: 1,\n            auth_plugin_data: challenge_chain,\n            server_capabilities: self.capabilities,\n            server_default_collation: 45,\n            status: Status::empty(),\n            auth_plugin: Some(AuthPlugin::MySqlNativePassword),\n        };\n        self.stream.push(&handshake, ())?;\n        self.stream.flush().await?;\n\n        let resp = loop {\n            let Some(payload) = self.stream.recv().await? else {\n                return Err(MySqlError::Eof);\n            };\n            let resp = HandshakeResponse::decode_with(payload, &mut self.capabilities)\n                .map_err(MySqlError::decode)?;\n\n            trace!(?resp, \"Handshake response\");\n            info!(capabilities=?self.capabilities, username=%resp.username, \"User handshake\");\n\n            if self.capabilities.contains(Capabilities::SSL) {\n                if self.stream.is_tls() {\n                    break resp;\n                }\n                self.stream = self.stream.upgrade(self.tls_config.clone()).await?;\n                continue;\n            } else {\n                self.send_error(1002, \"Warpgate requires TLS - please enable it in your client: add `--ssl` on the CLI or add `?sslMode=PREFERRED` to your database URI\").await?;\n                return Err(MySqlError::TlsNotSupportedByClient);\n            }\n        };\n\n        if resp.auth_plugin == Some(AuthPlugin::MySqlClearPassword) {\n            if let Some(mut response) = resp.auth_response.clone() {\n                let password = Secret::new(response.get_str_nul()?);\n                return self.run_authorization(resp, password).await;\n            }\n        }\n\n        let req = AuthSwitchRequest {\n            plugin: AuthPlugin::MySqlClearPassword,\n            data: Bytes::new(),\n        };\n        self.stream.push(&req, ())?;\n\n        // self.push(&RawBytes::<\n        self.stream.flush().await?;\n\n        let Some(response) = &self.stream.recv().await? else {\n            return Err(MySqlError::Eof);\n        };\n        let password = Secret::new(response.clone().get_str_nul()?);\n        self.run_authorization(resp, password).await\n    }\n\n    async fn send_error(&mut self, code: u16, message: &str) -> Result<(), MySqlError> {\n        self.stream.push(\n            &ErrPacket {\n                error_code: code,\n                error_message: message.to_owned(),\n                sql_state: None,\n            },\n            (),\n        )?;\n        self.stream.flush().await?;\n        Ok(())\n    }\n\n    pub async fn run_authorization(\n        mut self,\n        handshake: HandshakeResponse,\n        password: Secret<String>,\n    ) -> Result<(), MySqlError> {\n        let selector: AuthSelector = handshake.username.deref().into();\n\n        async fn fail<S: AsyncRead + AsyncWrite + Send + Unpin>(\n            this: &mut MySqlSession<S>,\n        ) -> Result<(), MySqlError> {\n            this.stream.push(\n                &ErrPacket {\n                    error_code: 1,\n                    error_message: \"Warpgate access denied\".to_owned(),\n                    sql_state: None,\n                },\n                (),\n            )?;\n            this.stream.flush().await?;\n            Ok(())\n        }\n\n        match selector {\n            AuthSelector::User {\n                username,\n                target_name,\n            } => {\n                let state_arc = self\n                    .services\n                    .auth_state_store\n                    .lock()\n                    .await\n                    .create(\n                        Some(&self.server_handle.lock().await.id()),\n                        &username,\n                        crate::common::PROTOCOL_NAME,\n                        &[CredentialKind::Password],\n                    )\n                    .await?\n                    .1;\n                let mut state = state_arc.lock().await;\n\n                let user_auth_result = {\n                    let credential = AuthCredential::Password(password);\n\n                    let mut cp = self.services.config_provider.lock().await;\n                    if cp.validate_credential(&username, &credential).await? {\n                        state.add_valid_credential(credential);\n                    }\n\n                    state.verify()\n                };\n\n                match user_auth_result {\n                    AuthResult::Accepted { user_info } => {\n                        self.services\n                            .auth_state_store\n                            .lock()\n                            .await\n                            .complete(state.id())\n                            .await;\n                        let target_auth_result = {\n                            self.services\n                                .config_provider\n                                .lock()\n                                .await\n                                .authorize_target(&user_info.username, &target_name)\n                                .await\n                                .map_err(MySqlError::other)?\n                        };\n                        if !target_auth_result {\n                            warn!(\n                                \"Target {} not authorized for user {}\",\n                                target_name, user_info.username\n                            );\n                            return fail(&mut self).await;\n                        }\n                        self.run_authorized(handshake, user_info, target_name).await\n                    }\n                    AuthResult::Rejected | AuthResult::Need(_) => fail(&mut self).await, // TODO SSO\n                }\n            }\n            AuthSelector::Ticket { secret } => {\n                match authorize_ticket(&self.services.db, &secret)\n                    .await\n                    .map_err(MySqlError::other)?\n                {\n                    Some((ticket, user_info)) => {\n                        info!(\"Authorized for {} with a ticket\", ticket.target);\n                        consume_ticket(&self.services.db, &ticket.id)\n                            .await\n                            .map_err(MySqlError::other)?;\n\n                        self.run_authorized(handshake, user_info, ticket.target)\n                            .await\n                    }\n                    _ => fail(&mut self).await,\n                }\n            }\n        }\n    }\n\n    async fn run_authorized(\n        mut self,\n        handshake: HandshakeResponse,\n        user_info: AuthStateUserInfo,\n        target_name: String,\n    ) -> Result<(), MySqlError> {\n        self.stream.push(\n            &OkPacket {\n                affected_rows: 0,\n                last_insert_id: 0,\n                status: Status::empty(),\n                warnings: 0,\n            },\n            (),\n        )?;\n        self.stream.flush().await?;\n\n        let target = {\n            self.services\n                .config_provider\n                .lock()\n                .await\n                .list_targets()\n                .await?\n                .iter()\n                .filter_map(|t| match t.options {\n                    TargetOptions::MySql(ref options) => Some((t, options)),\n                    _ => None,\n                })\n                .find(|(t, _)| t.name == target_name)\n                .map(|(t, opt)| (t.clone(), opt.clone()))\n        };\n\n        let Some((target, mysql_options)) = target else {\n            warn!(\"Selected target not found\");\n            self.stream.push(\n                &ErrPacket {\n                    error_code: 1,\n                    error_message: \"Warpgate access denied\".to_owned(),\n                    sql_state: None,\n                },\n                (),\n            )?;\n            self.stream.flush().await?;\n            return Ok(());\n        };\n\n        {\n            let handle = self.server_handle.lock().await;\n            handle.set_user_info(user_info).await?;\n            handle.set_target(&target).await?;\n        }\n\n        self.run_authorized_inner(handshake, mysql_options).await\n    }\n\n    async fn run_authorized_inner(\n        mut self,\n        handshake: HandshakeResponse,\n        options: TargetMySqlOptions,\n    ) -> Result<(), MySqlError> {\n        self.database = handshake.database.clone();\n        self.username = Some(handshake.username);\n        if let Some(ref database) = handshake.database {\n            info!(\"Selected database: {database}\");\n        }\n\n        let mut client = match MySqlClient::connect(\n            &options,\n            ConnectionOptions {\n                collation: handshake.collation,\n                database: handshake.database,\n                max_packet_size: handshake.max_packet_size,\n                capabilities: self.capabilities,\n            },\n        )\n        .await\n        {\n            Err(error) => {\n                error!(%error, \"Target connection failed\");\n                self.send_error(1045, \"Access denied\").await?;\n                Err(error)\n            }\n            x => x,\n        }?;\n\n        loop {\n            self.stream.reset_sequence_id();\n            client.stream.reset_sequence_id();\n            let Some(payload) = self.stream.recv().await? else {\n                break;\n            };\n            trace!(?payload, \"server got packet\");\n\n            let com = payload.first();\n\n            // COM_QUERY\n            if com == Some(&0x03) {\n                let query = Query::decode(payload)?;\n                info!(query=%query.0, \"SQL\");\n\n                client.stream.push(&query, ())?;\n                client.stream.flush().await?;\n\n                let mut eof_ctr = 0;\n                loop {\n                    let Some(response) = client.stream.recv().await? else {\n                        return Err(MySqlError::Eof);\n                    };\n                    trace!(?response, \"client got packet\");\n                    self.stream.push(&&response[..], ())?;\n                    self.stream.flush().await?;\n                    if let Some(com) = response.first() {\n                        if com == &0xfe {\n                            if self.capabilities.contains(Capabilities::DEPRECATE_EOF) {\n                                break;\n                            }\n                            eof_ctr += 1;\n                            if eof_ctr == 2 {\n                                // todo check multiple results\n                                break;\n                            }\n                        }\n                        if com == &0 || com == &0xff {\n                            break;\n                        }\n                    }\n                }\n            // COM_QUIT\n            } else if com == Some(&0x01) {\n                break;\n            // COM_INIT_DB\n            } else if com == Some(&0x02) {\n                let mut buf = payload.clone();\n                buf.advance(1);\n                let db = buf.get_str(buf.len())?;\n                self.database = Some(db.clone());\n                info!(\"Selected database: {db}\");\n                client.stream.push(&&payload[..], ())?;\n                client.stream.flush().await?;\n                self.passthrough_until_result(&mut client).await?;\n            // COM_FIELD_LIST, COM_PING, COM_RESET_CONNECTION\n            } else if com == Some(&0x04) || com == Some(&0x0e) || com == Some(&0x1f) {\n                client.stream.push(&&payload[..], ())?;\n                client.stream.flush().await?;\n                self.passthrough_until_result(&mut client).await?;\n            } else if let Some(com) = com {\n                warn!(\"Unknown packet type {com}\");\n                self.send_error(1047, \"Not implemented\").await?;\n            } else {\n                break;\n            }\n        }\n\n        Ok(())\n    }\n\n    async fn passthrough_until_result(\n        &mut self,\n        client: &mut MySqlClient,\n    ) -> Result<(), MySqlError> {\n        loop {\n            let Some(response) = client.stream.recv().await? else {\n                return Err(MySqlError::Eof);\n            };\n            trace!(?response, \"client got packet\");\n            self.stream.push(&&response[..], ())?;\n            self.stream.flush().await?;\n            if let Some(com) = response.first() {\n                if com == &0 || com == &0xff || com == &0xfe {\n                    break;\n                }\n            }\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-mysql/src/session_handle.rs",
    "content": "use tokio::sync::mpsc;\nuse warpgate_core::SessionHandle;\n\npub struct MySqlSessionHandle {\n    abort_tx: mpsc::UnboundedSender<()>,\n}\n\nimpl MySqlSessionHandle {\n    pub fn new() -> (Self, mpsc::UnboundedReceiver<()>) {\n        let (abort_tx, abort_rx) = mpsc::unbounded_channel();\n        (MySqlSessionHandle { abort_tx }, abort_rx)\n    }\n}\n\nimpl SessionHandle for MySqlSessionHandle {\n    fn close(&mut self) {\n        let _ = self.abort_tx.send(());\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-mysql/src/stream.rs",
    "content": "use bytes::{Bytes, BytesMut};\nuse mysql_common::proto::codec::error::PacketCodecError;\nuse mysql_common::proto::codec::PacketCodec;\nuse tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};\nuse tracing::*;\nuse warpgate_database_protocols::io::Encode;\nuse warpgate_tls::{MaybeTlsStream, MaybeTlsStreamError, UpgradableStream};\n\n#[derive(thiserror::Error, Debug)]\npub enum MySqlStreamError {\n    #[error(\"packet codec error: {0}\")]\n    Codec(#[from] PacketCodecError),\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n}\n\npub struct MySqlStream<S, TS>\nwhere\n    S: UpgradableStream<TS>,\n    S: AsyncRead + AsyncWrite + Unpin,\n    TS: AsyncRead + AsyncWrite + Unpin,\n{\n    stream: MaybeTlsStream<S, TS>,\n    codec: PacketCodec,\n    inbound_buffer: BytesMut,\n    outbound_buffer: BytesMut,\n}\n\nimpl<S, TS> MySqlStream<S, TS>\nwhere\n    S: UpgradableStream<TS>,\n    S: AsyncRead + AsyncWrite + Unpin,\n    TS: AsyncRead + AsyncWrite + Unpin,\n{\n    pub fn new(stream: S) -> Self {\n        Self {\n            stream: MaybeTlsStream::new(stream),\n            codec: PacketCodec::default(),\n            inbound_buffer: BytesMut::new(),\n            outbound_buffer: BytesMut::new(),\n        }\n    }\n\n    pub fn push<'a, C, P: Encode<'a, C>>(\n        &mut self,\n        packet: &'a P,\n        context: C,\n    ) -> Result<(), MySqlStreamError> {\n        let mut buf = vec![];\n        packet.encode_with(&mut buf, context);\n        self.codec.encode(&mut &*buf, &mut self.outbound_buffer)?;\n        Ok(())\n    }\n\n    pub async fn flush(&mut self) -> std::io::Result<()> {\n        trace!(outbound_buffer=?self.outbound_buffer, \"sending\");\n        self.stream.write_all(&self.outbound_buffer[..]).await?;\n        self.outbound_buffer = BytesMut::new();\n        self.stream.flush().await?;\n        Ok(())\n    }\n\n    pub async fn recv(&mut self) -> Result<Option<Bytes>, MySqlStreamError> {\n        let mut payload = BytesMut::new();\n        loop {\n            {\n                let got_full_packet = self.codec.decode(&mut self.inbound_buffer, &mut payload)?;\n                if got_full_packet {\n                    trace!(?payload, \"received\");\n                    return Ok(Some(payload.freeze()));\n                }\n            }\n            let read_bytes = self.stream.read_buf(&mut self.inbound_buffer).await?;\n            if read_bytes == 0 {\n                return Ok(None);\n            }\n            trace!(inbound_buffer=?self.inbound_buffer, \"received chunk\");\n        }\n    }\n\n    pub fn reset_sequence_id(&mut self) {\n        self.codec.reset_seq_id();\n    }\n\n    pub async fn upgrade(\n        mut self,\n        config: <S as UpgradableStream<TS>>::UpgradeConfig,\n    ) -> Result<Self, MaybeTlsStreamError> {\n        self.stream = self.stream.upgrade(config).await?;\n        Ok(self)\n    }\n\n    pub fn is_tls(&self) -> bool {\n        match self.stream {\n            MaybeTlsStream::Raw(_) => false,\n            MaybeTlsStream::Tls(_) => true,\n            MaybeTlsStream::Upgrading => false,\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-postgres/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-protocol-postgres\"\nversion = \"0.22.0\"\n\n[dependencies]\nwarpgate-common = { version = \"*\", path = \"../warpgate-common\", default-features = false }\nwarpgate-tls = { version = \"*\", path = \"../warpgate-tls\", default-features = false }\nwarpgate-core = { version = \"*\", path = \"../warpgate-core\", default-features = false }\nanyhow.workspace = true\nasync-trait = { version = \"0.1\", default-features = false }\ntokio.workspace = true\ntracing.workspace = true\nuuid.workspace = true\nbytes.workspace = true\nrustls.workspace = true\ntokio-rustls.workspace = true\nthiserror.workspace = true\nrustls-native-certs = { version = \"0.8\", default-features = false }\npgwire = { version = \"0.30\", default-features = false, features = [\n    \"server-api-aws-lc-rs\",\n] }\nrsasl = { version = \"2.1.0\", default-features = false, features = [\n    \"config_builder\",\n    \"scram-sha-2\",\n    \"std\",\n    \"plain\",\n    \"provider\",\n] }\nfutures.workspace = true\nhumantime = { version = \"2.1\", default-features = false }\nsocket2 = { version = \"0.5\", features = [\"all\"] }\n"
  },
  {
    "path": "warpgate-protocol-postgres/src/client.rs",
    "content": "use std::collections::BTreeMap;\nuse std::fmt::Debug;\nuse std::io::Write;\nuse std::sync::Arc;\n\nuse pgwire::messages::PgWireBackendMessage;\nuse rsasl::config::SASLConfig;\nuse rsasl::prelude::{Mechname, SASLClient};\nuse tokio::net::TcpStream;\nuse tokio_rustls::client::TlsStream;\nuse tracing::*;\nuse warpgate_common::TargetPostgresOptions;\nuse warpgate_tls::{configure_tls_connector, TlsMode};\n\nuse crate::error::PostgresError;\nuse crate::stream::{PgWireGenericBackendMessage, PostgresEncode, PostgresStream};\n\npub struct PostgresClient {\n    pub stream: PostgresStream<TcpStream, TlsStream<TcpStream>>,\n}\n\npub struct ConnectionOptions {\n    pub protocol_number_major: u16,\n    pub protocol_number_minor: u16,\n    pub parameters: BTreeMap<String, String>,\n}\n\nimpl Default for ConnectionOptions {\n    fn default() -> Self {\n        ConnectionOptions {\n            protocol_number_major: 3,\n            protocol_number_minor: 0,\n            parameters: BTreeMap::new(),\n        }\n    }\n}\n\nstruct SaslBufferWriter<'a>(&'a mut Option<Vec<u8>>);\n\nimpl Write for SaslBufferWriter<'_> {\n    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {\n        if let Some(data) = self.0.as_mut() {\n            data.extend_from_slice(buf);\n        } else {\n            *self.0 = Some(buf.to_vec());\n        }\n        Ok(buf.len())\n    }\n\n    fn flush(&mut self) -> std::io::Result<()> {\n        Ok(())\n    }\n}\n\nimpl PostgresClient {\n    pub async fn connect(\n        target: &TargetPostgresOptions,\n        options: ConnectionOptions,\n    ) -> Result<Self, PostgresError> {\n        let stream = TcpStream::connect((target.host.clone(), target.port)).await?;\n        stream.set_nodelay(true)?;\n\n        let mut stream = PostgresStream::new(stream);\n\n        if target.tls.mode != TlsMode::Disabled {\n            stream.push(pgwire::messages::startup::SslRequest::new())?;\n            stream.flush().await?;\n\n            let Some(response) = stream\n                .recv::<pgwire::messages::response::SslResponse>()\n                .await?\n            else {\n                return Err(PostgresError::Eof);\n            };\n\n            match target.tls.mode {\n                TlsMode::Disabled => unreachable!(),\n                TlsMode::Required => {\n                    if response == pgwire::messages::response::SslResponse::Refuse {\n                        return Err(PostgresError::TlsNotSupported);\n                    }\n                }\n                TlsMode::Preferred => {\n                    if response == pgwire::messages::response::SslResponse::Refuse {\n                        warn!(\"TLS not supported by target\");\n                    }\n                }\n            }\n\n            if response == pgwire::messages::response::SslResponse::Accept {\n                let accept_invalid_certs = !target.tls.verify;\n                let accept_invalid_hostname = false; // ca + hostname verification\n                let client_config = Arc::new(\n                    configure_tls_connector(accept_invalid_certs, accept_invalid_hostname, None)\n                        .await?,\n                );\n\n                stream = stream\n                    .upgrade((\n                        target\n                            .host\n                            .clone()\n                            .try_into()\n                            .map_err(|_| PostgresError::InvalidDomainName)?,\n                        client_config,\n                    ))\n                    .await?;\n                info!(\"Target connection upgraded to TLS\");\n            }\n        }\n\n        let mut startup = pgwire::messages::startup::Startup::new();\n        startup.parameters = options.parameters.clone();\n        startup\n            .parameters\n            .insert(\"user\".to_owned(), target.username.clone());\n        startup.protocol_number_major = options.protocol_number_major;\n        startup.protocol_number_minor = options.protocol_number_minor;\n\n        stream.push(startup)?;\n        stream.flush().await?;\n\n        loop {\n            let Some(payload) = stream.recv::<PgWireGenericBackendMessage>().await? else {\n                return Err(PostgresError::Eof);\n            };\n\n            let get_password = || {\n                target\n                    .password\n                    .as_ref()\n                    .ok_or(PostgresError::PasswordRequired)\n            };\n\n            match payload.0 {\n                PgWireBackendMessage::ErrorResponse(err) => {\n                    return Err(PostgresError::from(err));\n                }\n                PgWireBackendMessage::Authentication(auth) => match auth {\n                    pgwire::messages::startup::Authentication::Ok => {\n                        info!(\"Authenticated at target\");\n                        break;\n                    }\n                    pgwire::messages::startup::Authentication::CleartextPassword => {\n                        let password = get_password()?;\n                        let password_message =\n                            pgwire::messages::startup::Password::new(password.into());\n                        stream.push(password_message)?;\n                        stream.flush().await?;\n                    }\n                    pgwire::messages::startup::Authentication::MD5Password(scramble) => {\n                        let password = get_password()?;\n                        let hashed = pgwire::api::auth::md5pass::hash_md5_password(\n                            &target.username,\n                            password,\n                            &scramble,\n                        );\n                        let password_message = pgwire::messages::startup::Password::new(hashed);\n                        stream.push(password_message)?;\n                        stream.flush().await?;\n                    }\n                    pgwire::messages::startup::Authentication::SASL(mechanisms) => {\n                        let password = get_password()?;\n                        PostgresClient::run_sasl_auth(\n                            &mut stream,\n                            mechanisms,\n                            &target.username,\n                            password,\n                        )\n                        .await?;\n                    }\n                    x => {\n                        return Err(PostgresError::ProtocolError(format!(\n                            \"Unsupported authentication method: {:?}\",\n                            x\n                        )));\n                    }\n                },\n                _ => {\n                    return Err(PostgresError::ProtocolError(\n                        \"Expected authentication\".to_owned(),\n                    ));\n                }\n            }\n        }\n\n        Ok(Self { stream })\n    }\n\n    async fn run_sasl_auth(\n        stream: &mut PostgresStream<TcpStream, TlsStream<TcpStream>>,\n        mechanisms: Vec<String>,\n        username: &str,\n        password: &str,\n    ) -> Result<(), PostgresError> {\n        let cfg = SASLConfig::with_credentials(None, username.into(), password.into())?;\n        let sasl = SASLClient::new(cfg);\n        let mut session = sasl.start_suggested(\n            &mechanisms\n                .iter()\n                .map(|x| Mechname::parse(x.as_bytes()))\n                .filter_map(Result::ok)\n                .collect::<Vec<_>>(),\n        )?;\n\n        let mut data: Option<Vec<u8>> = None;\n        if !session.are_we_first() {\n            return Err(PostgresError::ProtocolError(\n                \"SASL mechanism expects server to send data first\".to_owned(),\n            ));\n        }\n\n        let mut is_first_response = true;\n        while {\n            let mut data_to_send = None;\n\n            let state = {\n                let mut writer = SaslBufferWriter(&mut data_to_send);\n                session.step(data.as_deref(), &mut writer)?\n            };\n\n            if let Some(data) = data_to_send {\n                if is_first_response {\n                    let selected_mechanism = session.get_mechname();\n                    debug!(\"Selected SASL mechanism: {selected_mechanism:?}\");\n                    stream.push(pgwire::messages::startup::SASLInitialResponse::new(\n                        selected_mechanism.to_string(),\n                        Some(data.into()),\n                    ))?;\n                    is_first_response = false;\n                } else {\n                    stream.push(pgwire::messages::startup::SASLResponse::new(data.into()))?;\n                };\n                stream.flush().await?;\n            }\n\n            state.is_running()\n        } {\n            let Some(payload) = stream.recv::<PgWireGenericBackendMessage>().await? else {\n                return Err(PostgresError::Eof);\n            };\n\n            match payload.0 {\n                PgWireBackendMessage::ErrorResponse(response) => return Err(response.into()),\n                PgWireBackendMessage::Authentication(\n                    pgwire::messages::startup::Authentication::SASLContinue(msg),\n                ) => {\n                    data = Some(msg.to_vec());\n                }\n                PgWireBackendMessage::Authentication(\n                    pgwire::messages::startup::Authentication::SASLFinal(msg),\n                ) => {\n                    data = Some(msg.to_vec());\n                }\n                payload => {\n                    return Err(PostgresError::ProtocolError(format!(\n                        \"Unexpected message: {payload:?}\",\n                    )));\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    pub async fn recv(&mut self) -> Result<Option<PgWireGenericBackendMessage>, PostgresError> {\n        self.stream\n            .recv::<PgWireGenericBackendMessage>()\n            .await\n            .map_err(Into::into)\n    }\n\n    pub async fn send<M: PostgresEncode + Debug>(\n        &mut self,\n        message: M,\n    ) -> Result<(), PostgresError> {\n        self.stream.push(message)?;\n        self.stream.flush().await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-postgres/src/common.rs",
    "content": "use warpgate_common::ProtocolName;\n\npub const PROTOCOL_NAME: ProtocolName = \"PostgreSQL\";\n"
  },
  {
    "path": "warpgate-protocol-postgres/src/error.rs",
    "content": "use std::error::Error;\nuse std::string::FromUtf8Error;\n\nuse pgwire::error::PgWireError;\nuse pgwire::messages::response::ErrorResponse;\nuse rsasl::prelude::{SASLError, SessionError};\nuse warpgate_common::WarpgateError;\nuse warpgate_tls::{MaybeTlsStreamError, RustlsSetupError};\n\nuse crate::stream::PostgresStreamError;\n\n#[derive(thiserror::Error, Debug)]\npub enum PostgresError {\n    #[error(\"protocol error: {0}\")]\n    ProtocolError(String),\n    #[error(\"remote error: {0:?}\")]\n    RemoteError(ErrorResponse),\n    #[error(\"decode: {0}\")]\n    Decode(#[from] PgWireError),\n    #[error(\"sudden disconnection\")]\n    Eof,\n    #[error(\"stream: {0}\")]\n    Stream(#[from] PostgresStreamError),\n    #[error(\"server doesn't offer TLS\")]\n    TlsNotSupported,\n    #[error(\"TLS setup failed: {0}\")]\n    TlsSetup(#[from] RustlsSetupError),\n    #[error(\"TLS stream error: {0}\")]\n    Tls(#[from] MaybeTlsStreamError),\n    #[error(\"Invalid domain name\")]\n    InvalidDomainName,\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(\"UTF-8: {0}\")]\n    Utf8(#[from] FromUtf8Error),\n    #[error(\"SASL: {0}\")]\n    Sasl(#[from] SASLError),\n    #[error(\"SASL session: {0}\")]\n    SaslSession(#[from] SessionError),\n    #[error(\"Password is required for authentication\")]\n    PasswordRequired,\n    #[error(transparent)]\n    Warpgate(#[from] WarpgateError),\n    #[error(transparent)]\n    Other(Box<dyn Error + Send + Sync>),\n}\n\nimpl PostgresError {\n    pub fn other<E: Error + Send + Sync + 'static>(err: E) -> Self {\n        Self::Other(Box::new(err))\n    }\n}\n\nimpl From<ErrorResponse> for PostgresError {\n    fn from(e: ErrorResponse) -> Self {\n        PostgresError::RemoteError(e)\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-postgres/src/lib.rs",
    "content": "mod client;\nmod common;\nmod error;\nmod session;\nmod session_handle;\nmod stream;\n\nuse std::fmt::Debug;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse anyhow::{Context, Result};\nuse futures::TryStreamExt;\nuse rustls::server::NoClientAuth;\nuse rustls::ServerConfig;\nuse session::PostgresSession;\nuse session_handle::PostgresSessionHandle;\nuse socket2::{Socket, TcpKeepalive};\nuse tracing::*;\nuse warpgate_common::ListenEndpoint;\nuse warpgate_core::{ProtocolServer, Services, SessionStateInit, State};\nuse warpgate_tls::{\n    ResolveServerCert, TlsCertificateAndPrivateKey, TlsCertificateBundle, TlsPrivateKey,\n};\n\npub struct PostgresProtocolServer {\n    services: Services,\n}\n\nimpl PostgresProtocolServer {\n    pub async fn new(services: &Services) -> Result<Self> {\n        Ok(PostgresProtocolServer {\n            services: services.clone(),\n        })\n    }\n}\n\nimpl ProtocolServer for PostgresProtocolServer {\n    async fn run(self, address: ListenEndpoint) -> Result<()> {\n        let certificate_and_key = {\n            let config = self.services.config.lock().await;\n            let paths_rel_to = self.services.global_params.paths_relative_to();\n            let certificate_path = paths_rel_to.join(&config.store.postgres.certificate);\n            let key_path = paths_rel_to.join(&config.store.postgres.key);\n\n            TlsCertificateAndPrivateKey {\n                certificate: TlsCertificateBundle::from_file(&certificate_path)\n                    .await\n                    .with_context(|| {\n                        format!(\"reading SSL private key from '{}'\", key_path.display())\n                    })?,\n                private_key: TlsPrivateKey::from_file(&key_path).await.with_context(|| {\n                    format!(\n                        \"reading SSL certificate from '{}'\",\n                        certificate_path.display()\n                    )\n                })?,\n            }\n        };\n\n        let tls_config = ServerConfig::builder_with_provider(Arc::new(\n            rustls::crypto::aws_lc_rs::default_provider(),\n        ))\n        .with_safe_default_protocol_versions()?\n        .with_client_cert_verifier(Arc::new(NoClientAuth))\n        .with_cert_resolver(Arc::new(ResolveServerCert(Arc::new(\n            certificate_and_key.into(),\n        ))));\n\n        let mut listener = address\n            .tcp_accept_stream()\n            .await\n            .context(\"accepting connection\")?;\n        loop {\n            let Some(stream) = listener.try_next().await? else {\n                return Ok(());\n            };\n\n            let remote_address = stream.peer_addr().context(\"getting peer address\")?;\n\n            // Enable TCP keepalive to prevent idle connections from timing out\n            // This is especially important during web auth approval wait\n            // Use socket2 to configure keepalive (tokio TcpStream doesn't expose it directly)\n            let socket = Socket::from(stream.into_std()?);\n            let keepalive = TcpKeepalive::new()\n                .with_time(Duration::from_secs(60)) // Start keepalive after 60s of inactivity\n                .with_interval(Duration::from_secs(10)) // Send probes every 10s\n                .with_retries(3); // 3 retries before considering dead\n            socket.set_tcp_keepalive(&keepalive)?;\n            socket.set_nodelay(true)?;\n            let stream = tokio::net::TcpStream::from_std(socket.into())?;\n\n            let tls_config = tls_config.clone();\n            let services = self.services.clone();\n            tokio::spawn(async move {\n                let (session_handle, mut abort_rx) = PostgresSessionHandle::new();\n\n                let server_handle = State::register_session(\n                    &services.state,\n                    &crate::common::PROTOCOL_NAME,\n                    SessionStateInit {\n                        remote_address: Some(remote_address),\n                        handle: Box::new(session_handle),\n                    },\n                )\n                .await?;\n\n                let wrapped_stream = server_handle.lock().await.wrap_stream(stream).await?;\n\n                let session = PostgresSession::new(\n                    server_handle,\n                    services,\n                    wrapped_stream,\n                    tls_config,\n                    remote_address,\n                )\n                .await;\n\n                let span = session.make_logging_span();\n                tokio::select! {\n                    result = session.run().instrument(span) => match result {\n                        Ok(_) => info!(\"Session ended\"),\n                        Err(e) => error!(error=%e, \"Session failed\"),\n                    },\n                    _ = abort_rx.recv() => {\n                        warn!(\"Session aborted by admin\");\n                    },\n                }\n\n                Ok::<(), anyhow::Error>(())\n            });\n        }\n    }\n\n    fn name(&self) -> &'static str {\n        \"PostgreSQL\"\n    }\n}\n\nimpl Debug for PostgresProtocolServer {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"PostgresProtocolServer\").finish()\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-postgres/src/session.rs",
    "content": "use std::net::SocketAddr;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse pgwire::error::ErrorInfo;\nuse pgwire::messages::{PgWireBackendMessage, PgWireFrontendMessage};\nuse rustls::ServerConfig;\nuse tokio::io::{AsyncRead, AsyncWrite};\nuse tokio::sync::Mutex;\nuse tokio::time;\nuse tokio_rustls::server::TlsStream;\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::auth::{\n    AuthCredential, AuthResult, AuthSelector, AuthStateUserInfo, CredentialKind,\n};\nuse warpgate_common::{Secret, TargetOptions, TargetPostgresOptions};\nuse warpgate_core::{\n    authorize_ticket, consume_ticket, ConfigProvider, Services, WarpgateServerHandle,\n};\n\nuse crate::client::{ConnectionOptions, PostgresClient};\nuse crate::error::PostgresError;\nuse crate::stream::{PgWireGenericFrontendMessage, PgWireStartupOrSslRequest, PostgresStream};\n\npub struct PostgresSession<S: AsyncRead + AsyncWrite + Send + Unpin> {\n    stream: PostgresStream<S, TlsStream<S>>,\n    tls_config: Arc<ServerConfig>,\n    username: Option<String>,\n    database: Option<String>,\n    server_handle: Arc<Mutex<WarpgateServerHandle>>,\n    id: Uuid,\n    services: Services,\n    remote_address: SocketAddr,\n}\n\nimpl<S: AsyncRead + AsyncWrite + Send + Unpin> PostgresSession<S> {\n    pub async fn new(\n        server_handle: Arc<Mutex<WarpgateServerHandle>>,\n        services: Services,\n        stream: S,\n        tls_config: ServerConfig,\n        remote_address: SocketAddr,\n    ) -> Self {\n        let id = server_handle.lock().await.id();\n\n        Self {\n            services,\n            tls_config: Arc::new(tls_config),\n            stream: PostgresStream::new(stream),\n            username: None,\n            database: None,\n            server_handle,\n            id,\n            remote_address,\n        }\n    }\n\n    pub fn make_logging_span(&self) -> tracing::Span {\n        let client_ip = self.remote_address.ip().to_string();\n        match self.username {\n            Some(ref username) => {\n                info_span!(\"PostgreSQL\", session=%self.id, session_username=%username, %client_ip)\n            }\n            None => info_span!(\"PostgreSQL\", session=%self.id, %client_ip),\n        }\n    }\n\n    pub async fn run(mut self) -> Result<(), PostgresError> {\n        let Some(mut initial_message) = self.stream.recv::<PgWireStartupOrSslRequest>().await?\n        else {\n            return Err(PostgresError::Eof);\n        };\n\n        if let PgWireStartupOrSslRequest::SslRequest(_) = &initial_message {\n            debug!(\"Received SslRequest\");\n            self.stream\n                .push(pgwire::messages::response::SslResponse::Accept)?;\n            self.stream.flush().await?;\n            self.stream = self.stream.upgrade(self.tls_config.clone()).await?;\n            debug!(\"TLS setup complete\");\n\n            let Some(next_message) = self.stream.recv::<PgWireStartupOrSslRequest>().await? else {\n                return Err(PostgresError::Eof);\n            };\n\n            initial_message = next_message;\n        }\n\n        let PgWireStartupOrSslRequest::Startup(startup) = initial_message else {\n            return Err(PostgresError::ProtocolError(\"expected Startup\".into()));\n        };\n\n        let username = startup.parameters.get(\"user\").cloned();\n        self.username = username.clone();\n        self.database = startup.parameters.get(\"database\").cloned();\n\n        self.run_authorization(startup, &username.unwrap_or(\"\".into()))\n            .await\n    }\n\n    pub async fn run_authorization(\n        mut self,\n        startup: pgwire::messages::startup::Startup,\n        username: &String,\n    ) -> Result<(), PostgresError> {\n        let selector: AuthSelector = username.into();\n\n        async fn fail<S: AsyncRead + AsyncWrite + Send + Unpin>(\n            this: &mut PostgresSession<S>,\n        ) -> Result<(), PostgresError> {\n            let error_info = ErrorInfo::new(\n                \"FATAL\".to_owned(),\n                \"28P01\".to_owned(),\n                \"Authentication failed\".to_owned(),\n            );\n\n            this.stream\n                .push(pgwire::messages::response::ErrorResponse::from(error_info))?;\n            this.stream.flush().await?;\n            Ok(())\n        }\n\n        match selector {\n            AuthSelector::User {\n                username,\n                target_name,\n            } => {\n                let state_arc = self\n                    .services\n                    .auth_state_store\n                    .lock()\n                    .await\n                    .create(\n                        Some(&self.server_handle.lock().await.id()),\n                        &username,\n                        crate::common::PROTOCOL_NAME,\n                        &[CredentialKind::Password],\n                    )\n                    .await?\n                    .1;\n\n                let mut auth_ok_sent = false;\n\n                loop {\n                    let user_auth_result = state_arc.lock().await.verify();\n\n                    match user_auth_result {\n                        AuthResult::Accepted { user_info } => {\n                            self.services\n                                .auth_state_store\n                                .lock()\n                                .await\n                                .complete(state_arc.lock().await.id())\n                                .await;\n                            let target_auth_result = {\n                                self.services\n                                    .config_provider\n                                    .lock()\n                                    .await\n                                    .authorize_target(&user_info.username, &target_name)\n                                    .await\n                                    .map_err(PostgresError::other)?\n                            };\n                            if !target_auth_result {\n                                warn!(\"Target {target_name} not authorized for user {username}\",);\n                                return fail(&mut self).await;\n                            }\n\n                            if !auth_ok_sent {\n                                self.stream\n                                    .push(pgwire::messages::startup::Authentication::Ok)?;\n                            }\n                            return self.run_authorized(startup, user_info, target_name).await;\n                        }\n                        AuthResult::Need(kinds) => {\n                            if kinds.contains(&CredentialKind::Password) {\n                                self.stream.push(\n                                    pgwire::messages::startup::Authentication::CleartextPassword,\n                                )?;\n                                self.stream.flush().await?;\n\n                                let Some(PgWireGenericFrontendMessage(\n                                    PgWireFrontendMessage::PasswordMessageFamily(message),\n                                )) = self.stream.recv::<PgWireGenericFrontendMessage>().await?\n                                else {\n                                    return Err(PostgresError::Eof);\n                                };\n\n                                let password = Secret::from(\n                                    message\n                                        .into_password()\n                                        .map_err(PostgresError::from)?\n                                        .password,\n                                );\n\n                                let mut state = state_arc.lock().await;\n\n                                let credential = AuthCredential::Password(password);\n\n                                if self\n                                    .services\n                                    .config_provider\n                                    .lock()\n                                    .await\n                                    .validate_credential(&username, &credential)\n                                    .await?\n                                {\n                                    state.add_valid_credential(credential);\n                                } else {\n                                    // Postgres CLI will just send the same password in a loop without prompting the user again\n                                    return fail(&mut self).await;\n                                }\n                            } else if kinds.contains(&CredentialKind::WebUserApproval) {\n                                // Only WebUserApproval is needed, i.e. the password was either correct or not required, otherwise just fail early\n\n                                let identification_string =\n                                    state_arc.lock().await.identification_string().to_owned();\n                                let auth_state_id = *state_arc.lock().await.id();\n                                let mut event = self\n                                    .services\n                                    .auth_state_store\n                                    .lock()\n                                    .await\n                                    .subscribe(auth_state_id);\n\n                                let login_url_result =\n                                    state_arc.lock().await.construct_web_approval_url(\n                                        &*self.services.config.lock().await,\n                                    );\n                                let login_url = match login_url_result {\n                                    Ok(login_url) => login_url,\n                                    Err(error) => {\n                                        error!(?error, \"Failed to construct external URL\");\n                                        return fail(&mut self).await;\n                                    }\n                                };\n\n                                if !auth_ok_sent {\n                                    self.stream\n                                        .push(pgwire::messages::startup::Authentication::Ok)?;\n                                    auth_ok_sent = true;\n                                }\n\n                                self.stream\n                                    .push(pgwire::messages::response::NoticeResponse::new(vec![\n                                        (b'S', \"WARNING\".into()),\n                                        (b'V', \"WARNING\".into()),\n                                        (b'C', \"WG001\".into()),\n                                        (b'M', \"Warpgate authentication: please open the following URL in your browser:\".into()),\n                                        (b'D', login_url.into()),\n                                        (b'H', format!(\n                                            \"Make sure you're seeing this security key: {}\\n\",\n                                            identification_string\n                                                .chars()\n                                                .map(|x| x.to_string())\n                                                .collect::<Vec<_>>()\n                                                .join(\" \")\n                                        )),\n                                    ]))?;\n                                self.stream.flush().await?;\n\n                                if !matches!(event.recv().await, Ok(AuthResult::Accepted { .. })) {\n                                    warn!(\"Web user approval failed\");\n                                    return fail(&mut self).await;\n                                }\n                            } else {\n                                return fail(&mut self).await;\n                            }\n                        }\n                        AuthResult::Rejected => return fail(&mut self).await,\n                    }\n                }\n            }\n            AuthSelector::Ticket { secret } => {\n                match authorize_ticket(&self.services.db, &secret)\n                    .await\n                    .map_err(PostgresError::other)?\n                {\n                    Some((ticket, user_info)) => {\n                        info!(\"Authorized for {} with a ticket\", ticket.target);\n                        consume_ticket(&self.services.db, &ticket.id)\n                            .await\n                            .map_err(PostgresError::other)?;\n\n                        self.stream\n                            .push(pgwire::messages::startup::Authentication::Ok)?;\n                        self.run_authorized(startup, user_info, ticket.target).await\n                    }\n                    _ => fail(&mut self).await,\n                }\n            }\n        }\n    }\n\n    async fn run_authorized(\n        mut self,\n        startup: pgwire::messages::startup::Startup,\n        user_info: AuthStateUserInfo,\n        target_name: String,\n    ) -> Result<(), PostgresError> {\n        self.stream.flush().await?;\n\n        let target = {\n            self.services\n                .config_provider\n                .lock()\n                .await\n                .list_targets()\n                .await?\n                .iter()\n                .filter_map(|t| match t.options {\n                    TargetOptions::Postgres(ref options) => Some((t, options)),\n                    _ => None,\n                })\n                .find(|(t, _)| t.name == target_name)\n                .map(|(t, opt)| (t.clone(), opt.clone()))\n        };\n\n        let Some((target, postgres_options)) = target else {\n            warn!(\"Selected target not found\");\n            self.send_error_response(\n                \"0W001\".into(),\n                format!(\"Warpgate target {target_name} not found\"),\n            )\n            .await?;\n            return Ok(());\n        };\n\n        {\n            let handle = self.server_handle.lock().await;\n            handle.set_user_info(user_info).await?;\n            handle.set_target(&target).await?;\n        }\n\n        self.run_authorized_inner(startup, postgres_options).await\n    }\n\n    async fn send_error_response(\n        &mut self,\n        code: String,\n        message: String,\n    ) -> Result<(), PostgresError> {\n        let error_info = ErrorInfo::new(\"FATAL\".to_owned(), code, message);\n        self.stream\n            .push(pgwire::messages::response::ErrorResponse::from(error_info))?;\n        self.stream.flush().await?;\n        Ok(())\n    }\n\n    async fn run_authorized_inner(\n        mut self,\n        startup: pgwire::messages::startup::Startup,\n        options: TargetPostgresOptions,\n    ) -> Result<(), PostgresError> {\n        let mut client = match PostgresClient::connect(\n            &options,\n            ConnectionOptions {\n                protocol_number_major: startup.protocol_number_major,\n                protocol_number_minor: startup.protocol_number_minor,\n                parameters: startup.parameters,\n            },\n        )\n        .await\n        {\n            Err(error) => {\n                self.send_error_response(\n                    \"0W002\".into(),\n                    \"Warpgate target connection failed\".into(),\n                )\n                .await?;\n                Err(error)\n            }\n            x => x,\n        }?;\n\n        // Parse idle timeout from config\n        let idle_timeout = options\n            .idle_timeout\n            .as_ref()\n            .and_then(|s| {\n                let trimmed = s.trim();\n                if trimmed.is_empty() {\n                    None\n                } else {\n                    humantime::parse_duration(trimmed)\n                        .map_err(|e| {\n                            warn!(\n                                timeout_string = %trimmed,\n                                error = %e,\n                                \"Invalid idle_timeout value, falling back to default\"\n                            );\n                            e\n                        })\n                        .ok()\n                }\n            })\n            .unwrap_or(Duration::from_secs(60 * 10)); // Default 10 minutes\n\n        if idle_timeout.as_secs() > 0 {\n            info!(\n                idle_timeout_seconds = idle_timeout.as_secs(),\n                \"Using configured idle timeout for session\"\n            );\n        }\n\n        let mut last_activity = std::time::Instant::now();\n        let check_interval = Duration::from_secs(5); // Check idle timeout every 5 seconds\n\n        loop {\n            let elapsed = last_activity.elapsed();\n            if elapsed > idle_timeout {\n                info!(\n                    idle_seconds = elapsed.as_secs(),\n                    timeout_seconds = idle_timeout.as_secs(),\n                    \"Session idle timeout exceeded, closing connection\"\n                );\n                self.send_error_response(\n                    \"57P01\".into(),\n                    format!(\n                        \"Session idle for {} exceeded configured timeout of {}. Please reconnect.\",\n                        humantime::format_duration(elapsed),\n                        humantime::format_duration(idle_timeout)\n                    ),\n                )\n                .await?;\n                break;\n            }\n\n            let remaining_timeout = idle_timeout - elapsed;\n            let select_timeout = remaining_timeout.min(check_interval);\n\n            tokio::select! {\n                c_to_s = time::timeout(select_timeout, self.stream.recv::<PgWireGenericFrontendMessage>()) => {\n                    match c_to_s {\n                        Ok(Ok(Some(msg))) => {\n                            last_activity = std::time::Instant::now(); // Update activity on client message\n                            self.maybe_log_client_msg(&msg.0);\n                            client.send(msg).await?;\n                        }\n                        Ok(Ok(None)) => {\n                            break\n                        }\n                        Ok(Err(err)) => {\n                            error!(error=%err, \"Error receiving message\");\n                            break\n                        }\n                        Err(_) => {\n                            // Timeout - check if we've exceeded idle timeout\n                            continue;\n                        }\n                    };\n                },\n                s_to_c = client.recv() => {\n                    match s_to_c {\n                        Ok(Some(msg)) => {\n                            last_activity = std::time::Instant::now(); // Update activity on server message\n                            self.maybe_log_server_msg(&msg.0);\n                            self.stream.push(msg)?;\n                            self.stream.flush().await?;\n                        }\n                        Ok(None) => {\n                            break\n                        }\n                        Err(err) => {\n                            error!(error=%err, \"Error receiving message\");\n                            break\n                        }\n                    };\n                }\n            };\n        }\n\n        Ok(())\n    }\n\n    fn maybe_log_client_msg(&self, msg: &PgWireFrontendMessage) {\n        debug!(?msg, \"C->S message\");\n        match msg {\n            PgWireFrontendMessage::Parse(query) => {\n                info!(query_name=?query.name, query=query.query, \"Preparing query\");\n            }\n            PgWireFrontendMessage::Execute(query) => {\n                info!(query_name=?query.name, \"Executing prepared query\");\n            }\n            PgWireFrontendMessage::Query(query) => {\n                info!(query=%query.query, \"Query\");\n            }\n            _ => (),\n        }\n    }\n\n    fn maybe_log_server_msg(&self, msg: &PgWireBackendMessage) {\n        debug!(?msg, \"S->C message\");\n        if let PgWireBackendMessage::ErrorResponse(error) = msg {\n            info!(?error, \"PostgreSQL error\");\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-postgres/src/session_handle.rs",
    "content": "use tokio::sync::mpsc;\nuse warpgate_core::SessionHandle;\n\npub struct PostgresSessionHandle {\n    abort_tx: mpsc::UnboundedSender<()>,\n}\n\nimpl PostgresSessionHandle {\n    pub fn new() -> (Self, mpsc::UnboundedReceiver<()>) {\n        let (abort_tx, abort_rx) = mpsc::unbounded_channel();\n        (PostgresSessionHandle { abort_tx }, abort_rx)\n    }\n}\n\nimpl SessionHandle for PostgresSessionHandle {\n    fn close(&mut self) {\n        let _ = self.abort_tx.send(());\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-postgres/src/stream.rs",
    "content": "use std::fmt::Debug;\n\nuse bytes::BytesMut;\nuse pgwire::error::{PgWireError, PgWireResult};\nuse pgwire::messages::{PgWireBackendMessage, PgWireFrontendMessage};\nuse tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};\nuse tracing::*;\nuse warpgate_tls::{MaybeTlsStream, MaybeTlsStreamError, UpgradableStream};\n\n#[derive(thiserror::Error, Debug)]\npub enum PostgresStreamError {\n    #[error(\"decode: {0}\")]\n    Decode(#[from] PgWireError),\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n}\n\npub(crate) trait PostgresEncode {\n    fn encode(&self, buf: &mut BytesMut) -> PgWireResult<()>\n    where\n        Self: Sized;\n}\n\npub(crate) trait PostgresDecode {\n    fn decode(buf: &mut BytesMut) -> PgWireResult<Option<Self>>\n    where\n        Self: Sized;\n}\n\n#[derive(Debug)]\npub(crate) enum PgWireStartupOrSslRequest {\n    Startup(pgwire::messages::startup::Startup),\n    SslRequest(pgwire::messages::startup::SslRequest),\n}\n\nimpl PostgresDecode for PgWireStartupOrSslRequest {\n    fn decode(buf: &mut BytesMut) -> PgWireResult<Option<Self>> {\n        if let Ok(Some(result)) = pgwire::messages::startup::SslRequest::decode(buf) {\n            return Ok(Some(Self::SslRequest(result)));\n        }\n        pgwire::messages::startup::Startup::decode(buf).map(|x| x.map(Self::Startup))\n    }\n}\n\n#[derive(Debug)]\npub(crate) struct PgWireGenericFrontendMessage(pub PgWireFrontendMessage);\n\n#[derive(Debug)]\npub(crate) struct PgWireGenericBackendMessage(pub PgWireBackendMessage);\n\nimpl PostgresDecode for PgWireGenericFrontendMessage {\n    fn decode(buf: &mut BytesMut) -> PgWireResult<Option<Self>> {\n        PgWireFrontendMessage::decode(buf).map(|x| x.map(PgWireGenericFrontendMessage))\n    }\n}\n\nimpl PostgresDecode for PgWireGenericBackendMessage {\n    fn decode(buf: &mut BytesMut) -> PgWireResult<Option<Self>> {\n        PgWireBackendMessage::decode(buf).map(|x| x.map(PgWireGenericBackendMessage))\n    }\n}\n\nimpl<T: pgwire::messages::Message> PostgresDecode for T {\n    fn decode(buf: &mut BytesMut) -> PgWireResult<Option<Self>> {\n        T::decode(buf)\n    }\n}\n\nimpl PostgresEncode for PgWireGenericFrontendMessage {\n    fn encode(&self, buf: &mut BytesMut) -> PgWireResult<()> {\n        self.0.encode(buf)\n    }\n}\n\nimpl PostgresEncode for PgWireGenericBackendMessage {\n    fn encode(&self, buf: &mut BytesMut) -> PgWireResult<()> {\n        self.0.encode(buf)\n    }\n}\n\nimpl<T: pgwire::messages::Message> PostgresEncode for T {\n    fn encode(&self, buf: &mut BytesMut) -> PgWireResult<()> {\n        self.encode(buf)\n    }\n}\n\npub(crate) struct PostgresStream<S, TS>\nwhere\n    S: UpgradableStream<TS>,\n    S: AsyncRead + AsyncWrite + Send + Unpin,\n    TS: AsyncRead + AsyncWrite + Unpin,\n{\n    stream: MaybeTlsStream<S, TS>,\n    inbound_buffer: BytesMut,\n    outbound_buffer: BytesMut,\n}\n\nimpl<S, TS> PostgresStream<S, TS>\nwhere\n    S: UpgradableStream<TS>,\n    S: AsyncRead + AsyncWrite + Send + Unpin,\n    TS: AsyncRead + AsyncWrite + Unpin,\n{\n    pub fn new(stream: S) -> Self {\n        Self {\n            stream: MaybeTlsStream::new(stream),\n            inbound_buffer: BytesMut::new(),\n            outbound_buffer: BytesMut::new(),\n        }\n    }\n\n    pub fn push<M: PostgresEncode + Debug>(\n        &mut self,\n        message: M,\n    ) -> Result<(), PostgresStreamError> {\n        trace!(?message, \"sending\");\n        message.encode(&mut self.outbound_buffer)?;\n        Ok(())\n    }\n\n    pub async fn flush(&mut self) -> std::io::Result<()> {\n        self.stream.write_all(&self.outbound_buffer[..]).await?;\n        self.outbound_buffer = BytesMut::new();\n        self.stream.flush().await?;\n        Ok(())\n    }\n\n    pub(crate) async fn recv<T: PostgresDecode + Debug>(\n        &mut self,\n    ) -> Result<Option<T>, PostgresStreamError> {\n        loop {\n            if let Some(message) = T::decode(&mut self.inbound_buffer)? {\n                trace!(?message, \"received\");\n                return Ok(Some(message));\n            };\n\n            let read_bytes = self.stream.read_buf(&mut self.inbound_buffer).await?;\n            if read_bytes == 0 {\n                return Ok(None);\n            }\n        }\n    }\n\n    pub(crate) async fn upgrade(\n        mut self,\n        config: <S as UpgradableStream<TS>>::UpgradeConfig,\n    ) -> Result<Self, MaybeTlsStreamError> {\n        self.stream = self.stream.upgrade(config).await?;\n        Ok(self)\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-protocol-ssh\"\nversion = \"0.22.0\"\n\n[dependencies]\nansi_term = { version = \"0.12\", default-features = false }\nanyhow.workspace = true\nasync-trait = { version = \"0.1\", default-features = false }\nbimap = { version = \"0.6\", default-features = false, features = [\"std\"] }\nbytes.workspace = true\ndialoguer.workspace = true\ncurve25519-dalek = { version = \"4.0.0\", default-features = false } # pin due to build fail on x86\ned25519-dalek = { version = \"2.0.0\", default-features = false } # pin due to build fail on x86 in 2.1\nfutures.workspace = true\nrussh.workspace = true\nserde.workspace = true\nsea-orm.workspace = true\nthiserror.workspace = true\ntime = { version = \"0.3\", default-features = false }\ntokio.workspace = true\ntracing.workspace = true\nuuid.workspace = true\nwarpgate-common = { version = \"*\", path = \"../warpgate-common\", default-features = false }\nwarpgate-core = { version = \"*\", path = \"../warpgate-core\", default-features = false }\nwarpgate-db-entities = { version = \"*\", path = \"../warpgate-db-entities\", default-features = false }\nzeroize = { version = \"^1.5\", default-features = false }\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/client/channel_direct_tcpip.rs",
    "content": "use anyhow::Result;\nuse bytes::Bytes;\nuse russh::client::Msg;\nuse russh::Channel;\nuse tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::SessionId;\n\nuse super::error::SshClientError;\nuse crate::{ChannelOperation, RCEvent};\n\npub struct DirectTCPIPChannel {\n    client_channel: Channel<Msg>,\n    channel_id: Uuid,\n    ops_rx: UnboundedReceiver<ChannelOperation>,\n    events_tx: UnboundedSender<RCEvent>,\n    session_id: SessionId,\n}\n\nimpl DirectTCPIPChannel {\n    pub fn new(\n        client_channel: Channel<Msg>,\n        channel_id: Uuid,\n        ops_rx: UnboundedReceiver<ChannelOperation>,\n        events_tx: UnboundedSender<RCEvent>,\n        session_id: SessionId,\n    ) -> Self {\n        DirectTCPIPChannel {\n            client_channel,\n            channel_id,\n            ops_rx,\n            events_tx,\n            session_id,\n        }\n    }\n\n    pub async fn run(mut self) -> Result<(), SshClientError> {\n        loop {\n            tokio::select! {\n                incoming_data = self.ops_rx.recv() => {\n                    match incoming_data {\n                        Some(ChannelOperation::Data(data)) => {\n                            self.client_channel.data(&*data).await?;\n                        }\n                        Some(ChannelOperation::Eof) => {\n                            self.client_channel.eof().await?;\n                        },\n                        Some(ChannelOperation::Close) => break,\n                        None => break,\n                        Some(operation) => {\n                            warn!(client_channel=%self.channel_id, ?operation, session=%self.session_id, \"unexpected client_channel operation\");\n                        }\n                    }\n                }\n                channel_event = self.client_channel.wait() => {\n                    match channel_event {\n                        Some(russh::ChannelMsg::Data { data }) => {\n                            let bytes: &[u8] = &data;\n                            self.events_tx.send(RCEvent::Output(\n                                self.channel_id,\n                                Bytes::from(bytes.to_vec()),\n                            )).map_err(|_| SshClientError::MpscError)?;\n                        }\n                        Some(russh::ChannelMsg::Close) => {\n                            self.events_tx.send(RCEvent::Close(self.channel_id)).map_err(|_| SshClientError::MpscError)?;\n                        },\n                        Some(russh::ChannelMsg::Success) => {\n                            self.events_tx.send(RCEvent::Success(self.channel_id)).map_err(|_| SshClientError::MpscError)?;\n                        },\n                        Some(russh::ChannelMsg::Eof) => {\n                            self.events_tx.send(RCEvent::Eof(self.channel_id)).map_err(|_| SshClientError::MpscError)?;\n                        }\n                        None => {\n                            self.events_tx.send(RCEvent::Close(self.channel_id)).map_err(|_| SshClientError::MpscError)?;\n                            break\n                        },\n                        Some(operation) => {\n                            warn!(client_channel=%self.channel_id, ?operation, session=%self.session_id, \"unexpected client_channel operation\");\n                        }\n                    }\n                }\n            }\n        }\n        Ok(())\n    }\n}\n\nimpl Drop for DirectTCPIPChannel {\n    fn drop(&mut self) {\n        info!(client_channel=%self.channel_id, session=%self.session_id, \"Closed\");\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/client/channel_session.rs",
    "content": "use anyhow::Result;\nuse bytes::Bytes;\nuse russh::client::Msg;\nuse russh::Channel;\nuse tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::SessionId;\n\nuse super::error::SshClientError;\nuse crate::{ChannelOperation, RCEvent};\n\npub struct SessionChannel {\n    client_channel: Channel<Msg>,\n    channel_id: Uuid,\n    ops_rx: UnboundedReceiver<ChannelOperation>,\n    events_tx: UnboundedSender<RCEvent>,\n    session_id: SessionId,\n    closed: bool,\n}\n\nimpl SessionChannel {\n    pub fn new(\n        client_channel: Channel<Msg>,\n        channel_id: Uuid,\n        ops_rx: UnboundedReceiver<ChannelOperation>,\n        events_tx: UnboundedSender<RCEvent>,\n        session_id: SessionId,\n    ) -> Self {\n        SessionChannel {\n            client_channel,\n            channel_id,\n            ops_rx,\n            events_tx,\n            session_id,\n            closed: false,\n        }\n    }\n\n    pub async fn run(mut self) -> Result<(), SshClientError> {\n        loop {\n            tokio::select! {\n                incoming_data = self.ops_rx.recv() => {\n                    match incoming_data {\n                        Some(ChannelOperation::Data(data)) => {\n                            self.client_channel.data(&*data).await?;\n                        }\n                        Some(ChannelOperation::ExtendedData { ext, data }) => {\n                            self.client_channel.extended_data(ext, &*data).await?;\n                        }\n                        Some(ChannelOperation::RequestPty(request)) => {\n                            self.client_channel.request_pty(\n                                true,\n                                &request.term,\n                                request.col_width,\n                                request.row_height,\n                                request.pix_width,\n                                request.pix_height,\n                                &request.modes,\n                            ).await?;\n                        }\n                        Some(ChannelOperation::ResizePty(request)) => {\n                            self.client_channel.window_change(\n                                request.col_width,\n                                request.row_height,\n                                request.pix_width,\n                                request.pix_height,\n                            ).await?;\n                        },\n                        Some(ChannelOperation::RequestShell) => {\n                            self.client_channel.request_shell(true).await?;\n                        },\n                        Some(ChannelOperation::RequestEnv(name, value)) => {\n                            self.client_channel.set_env(false, name, value).await?;\n                        },\n                        Some(ChannelOperation::RequestExec(command)) => {\n                            self.client_channel.exec(false, command).await?;\n                        },\n                        Some(ChannelOperation::RequestSubsystem(name)) => {\n                            self.client_channel.request_subsystem(false, &name).await?;\n                        },\n                        Some(ChannelOperation::Eof) => {\n                            self.client_channel.eof().await?;\n                        },\n                        Some(ChannelOperation::Signal(signal)) => {\n                            self.client_channel.signal(signal).await?;\n                        },\n                        Some(ChannelOperation::OpenShell) => unreachable!(),\n                        Some(ChannelOperation::OpenDirectTCPIP { .. }) => unreachable!(),\n                        Some(ChannelOperation::OpenDirectStreamlocal { .. }) => unreachable!(),\n                        Some(ChannelOperation::OpenX11 { .. }) => unreachable!(),\n                        Some(ChannelOperation::RequestX11(request)) => {\n                            self.client_channel.request_x11(\n                                true,\n                                request.single_conection,\n                                request.x11_auth_protocol,\n                                request.x11_auth_cookie,\n                                request.x11_screen_number,\n                            ).await?;\n                        },\n                        Some(ChannelOperation::AgentForward) => {\n                            self.client_channel.agent_forward(\n                                true,\n                            ).await?;\n                        }\n                        Some(ChannelOperation::Close) => break,\n                        None => break,\n                    }\n                }\n                channel_event = self.client_channel.wait() => {\n                    match channel_event {\n                        Some(russh::ChannelMsg::Data { data }) => {\n                            let bytes: &[u8] = &data;\n                            debug!(\"channel data: {bytes:?}\");\n                            self.events_tx.send(RCEvent::Output(\n                                self.channel_id,\n                                Bytes::from(bytes.to_vec()),\n                            )).map_err(|_| SshClientError::MpscError)?;\n                        }\n                        Some(russh::ChannelMsg::Close) => {\n                            break;\n                        },\n                        Some(russh::ChannelMsg::Success) => {\n                            self.events_tx.send(RCEvent::Success(self.channel_id)).map_err(|_| SshClientError::MpscError)?;\n                        },\n                        Some(russh::ChannelMsg::Failure) => {\n                            self.events_tx.send(RCEvent::ChannelFailure(self.channel_id)).map_err(|_| SshClientError::MpscError)?;\n                        },\n                        Some(russh::ChannelMsg::Eof) => {\n                            self.events_tx.send(RCEvent::Eof(self.channel_id)).map_err(|_| SshClientError::MpscError)?;\n                        }\n                        Some(russh::ChannelMsg::ExitStatus { exit_status }) => {\n                            self.events_tx.send(RCEvent::ExitStatus(self.channel_id, exit_status)).map_err(|_| SshClientError::MpscError)?;\n                        }\n                        Some(russh::ChannelMsg::WindowAdjusted { .. }) => { },\n                        Some(russh::ChannelMsg::ExitSignal {\n                            core_dumped, error_message, lang_tag, signal_name\n                        }) => {\n                            self.events_tx.send(RCEvent::ExitSignal {\n                                channel: self.channel_id, core_dumped, error_message, lang_tag, signal_name\n                            }).map_err(|_| SshClientError::MpscError)?;\n                        },\n                        Some(russh::ChannelMsg::XonXoff { client_can_do: _ }) => {\n                        }\n                        Some(russh::ChannelMsg::ExtendedData { data, ext }) => {\n                            let data: &[u8] = &data;\n                            self.events_tx.send(RCEvent::ExtendedData {\n                                channel: self.channel_id,\n                                data: Bytes::from(data.to_vec()),\n                                ext,\n                            }).map_err(|_| SshClientError::MpscError)?;\n                        }\n                        Some(msg) => {\n                            warn!(\"unhandled channel message: {:?}\", msg);\n                        }\n                        None => {\n                            break\n                        },\n                    }\n                }\n            }\n        }\n        self.close()?;\n        Ok(())\n    }\n\n    fn close(&mut self) -> Result<(), SshClientError> {\n        if !self.closed {\n            let _ = self\n                .events_tx\n                .send(RCEvent::Close(self.channel_id))\n                .map_err(|_| SshClientError::MpscError);\n            self.closed = true;\n        }\n        Ok(())\n    }\n}\n\nimpl Drop for SessionChannel {\n    fn drop(&mut self) {\n        let _ = self.close();\n        info!(channel=%self.channel_id, session=%self.session_id, \"Closed\");\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/client/error.rs",
    "content": "use std::error::Error;\n\nuse warpgate_common::WarpgateError;\n\n#[derive(thiserror::Error, Debug)]\npub enum SshClientError {\n    #[error(\"mpsc error\")]\n    MpscError,\n    #[error(\"russh error: {0}\")]\n    Russh(#[from] russh::Error),\n    #[error(transparent)]\n    Warpgate(#[from] WarpgateError),\n    #[error(transparent)]\n    Other(Box<dyn Error + Send + Sync>),\n}\n\nimpl SshClientError {\n    pub fn other<E: Error + Send + Sync + 'static>(err: E) -> Self {\n        Self::Other(Box::new(err))\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/client/handler.rs",
    "content": "use russh::client::{Msg, Session};\nuse russh::keys::{PublicKey, PublicKeyBase64};\nuse russh::Channel;\nuse tokio::sync::mpsc::UnboundedSender;\nuse tokio::sync::oneshot;\nuse tracing::*;\nuse warpgate_common::{SessionId, TargetSSHOptions};\nuse warpgate_core::Services;\n\nuse crate::known_hosts::{KnownHostValidationResult, KnownHosts};\nuse crate::{ConnectionError, ForwardedStreamlocalParams, ForwardedTcpIpParams};\n\n#[derive(Debug)]\npub enum ClientHandlerEvent {\n    HostKeyReceived(PublicKey),\n    HostKeyUnknown(PublicKey, oneshot::Sender<bool>),\n    ForwardedTcpIp(Channel<Msg>, ForwardedTcpIpParams),\n    ForwardedStreamlocal(Channel<Msg>, ForwardedStreamlocalParams),\n    ForwardedAgent(Channel<Msg>),\n    X11(Channel<Msg>, String, u32),\n    Disconnect,\n}\n\npub struct ClientHandler {\n    pub ssh_options: TargetSSHOptions,\n    pub event_tx: UnboundedSender<ClientHandlerEvent>,\n    pub services: Services,\n    pub session_id: SessionId,\n}\n\n#[derive(Debug, thiserror::Error)]\npub enum ClientHandlerError {\n    #[error(\"Connection error\")]\n    ConnectionError(ConnectionError),\n\n    #[error(\"SSH\")]\n    Ssh(#[from] russh::Error),\n\n    #[error(\"Internal error\")]\n    Internal,\n}\n\nimpl russh::client::Handler for ClientHandler {\n    type Error = ClientHandlerError;\n\n    async fn check_server_key(\n        &mut self,\n        server_public_key: &PublicKey,\n    ) -> Result<bool, Self::Error> {\n        let mut known_hosts = KnownHosts::new(&self.services.db);\n        self.event_tx\n            .send(ClientHandlerEvent::HostKeyReceived(\n                server_public_key.clone(),\n            ))\n            .map_err(|_| ClientHandlerError::ConnectionError(ConnectionError::Internal))?;\n        match known_hosts\n            .validate(\n                &self.ssh_options.host,\n                self.ssh_options.port,\n                server_public_key,\n            )\n            .await\n        {\n            Ok(KnownHostValidationResult::Valid) => Ok(true),\n            Ok(KnownHostValidationResult::Invalid {\n                key_type,\n                key_base64,\n            }) => {\n                warn!(session=%self.session_id, \"Host key is invalid!\");\n                Err(ClientHandlerError::ConnectionError(\n                    ConnectionError::HostKeyMismatch {\n                        received_key_type: server_public_key.algorithm(),\n                        received_key_base64: server_public_key.public_key_base64(),\n                        known_key_type: key_type,\n                        known_key_base64: key_base64,\n                    },\n                ))\n            }\n            Ok(KnownHostValidationResult::Unknown) => {\n                warn!(session=%self.session_id, \"Host key is unknown\");\n\n                let (tx, rx) = oneshot::channel();\n                self.event_tx\n                    .send(ClientHandlerEvent::HostKeyUnknown(\n                        server_public_key.clone(),\n                        tx,\n                    ))\n                    .map_err(|_| ClientHandlerError::Internal)?;\n                let accepted = rx.await.map_err(|_| ClientHandlerError::Internal)?;\n                if accepted {\n                    if let Err(error) = known_hosts\n                        .trust(\n                            &self.ssh_options.host,\n                            self.ssh_options.port,\n                            server_public_key,\n                        )\n                        .await\n                    {\n                        error!(?error, session=%self.session_id, \"Failed to save host key\");\n                    }\n                    Ok(true)\n                } else {\n                    Ok(false)\n                }\n            }\n            Err(error) => {\n                error!(?error, session=%self.session_id, \"Failed to verify the host key\");\n                Err(ClientHandlerError::Internal)\n            }\n        }\n    }\n\n    async fn server_channel_open_forwarded_tcpip(\n        &mut self,\n        channel: Channel<Msg>,\n        connected_address: &str,\n        connected_port: u32,\n        originator_address: &str,\n        originator_port: u32,\n        __session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let connected_address = connected_address.to_string();\n        let originator_address = originator_address.to_string();\n        let _ = self.event_tx.send(ClientHandlerEvent::ForwardedTcpIp(\n            channel,\n            ForwardedTcpIpParams {\n                connected_address,\n                connected_port,\n                originator_address,\n                originator_port,\n            },\n        ));\n        Ok(())\n    }\n\n    async fn server_channel_open_x11(\n        &mut self,\n        channel: Channel<Msg>,\n        originator_address: &str,\n        originator_port: u32,\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let originator_address = originator_address.to_string();\n        let _ = self.event_tx.send(ClientHandlerEvent::X11(\n            channel,\n            originator_address,\n            originator_port,\n        ));\n        Ok(())\n    }\n\n    async fn server_channel_open_forwarded_streamlocal(\n        &mut self,\n        channel: Channel<Msg>,\n        socket_path: &str,\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let socket_path = socket_path.to_string();\n        let _ = self.event_tx.send(ClientHandlerEvent::ForwardedStreamlocal(\n            channel,\n            ForwardedStreamlocalParams { socket_path },\n        ));\n        Ok(())\n    }\n\n    async fn server_channel_open_agent_forward(\n        &mut self,\n        channel: Channel<Msg>,\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let _ = self\n            .event_tx\n            .send(ClientHandlerEvent::ForwardedAgent(channel));\n        Ok(())\n    }\n}\n\nimpl Drop for ClientHandler {\n    fn drop(&mut self) {\n        let _ = self.event_tx.send(ClientHandlerEvent::Disconnect);\n        debug!(session=%self.session_id, \"Dropped\");\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/client/mod.rs",
    "content": "mod channel_direct_tcpip;\nmod channel_session;\nmod error;\nmod handler;\nuse std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::io;\nuse std::net::ToSocketAddrs;\nuse std::sync::Arc;\n\nuse anyhow::Result;\nuse bytes::Bytes;\nuse channel_direct_tcpip::DirectTCPIPChannel;\nuse channel_session::SessionChannel;\npub use error::SshClientError;\nuse futures::pin_mut;\nuse handler::ClientHandler;\nuse russh::client::{AuthResult, Handle, KeyboardInteractiveAuthResponse};\nuse russh::keys::{PrivateKeyWithHashAlg, PublicKey};\nuse russh::{kex, MethodKind, Preferred, Sig};\nuse tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};\nuse tokio::sync::{oneshot, Mutex};\nuse tokio::task::JoinHandle;\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::{SSHTargetAuth, SessionId, TargetSSHOptions};\nuse warpgate_core::Services;\n\nuse self::handler::ClientHandlerEvent;\nuse super::{ChannelOperation, DirectTCPIPParams};\nuse crate::client::handler::ClientHandlerError;\nuse crate::{load_keys, ForwardedStreamlocalParams, ForwardedTcpIpParams};\n\n#[derive(Debug, thiserror::Error)]\npub enum ConnectionError {\n    #[error(\"Host key mismatch\")]\n    HostKeyMismatch {\n        received_key_type: russh::keys::Algorithm,\n        received_key_base64: String,\n        known_key_type: String,\n        known_key_base64: String,\n    },\n\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n\n    #[error(transparent)]\n    Key(#[from] russh::keys::Error),\n\n    #[error(transparent)]\n    Ssh(#[from] russh::Error),\n\n    #[error(\"Could not resolve address\")]\n    Resolve,\n\n    #[error(\"Internal error\")]\n    Internal,\n\n    #[error(\"Aborted\")]\n    Aborted,\n\n    #[error(\"Authentication failed\")]\n    Authentication,\n}\n\n#[derive(Debug)]\npub enum RCEvent {\n    State(RCState),\n    Output(Uuid, Bytes),\n    Success(Uuid),\n    ChannelFailure(Uuid),\n    Eof(Uuid),\n    Close(Uuid),\n    Error(anyhow::Error),\n    ExitStatus(Uuid, u32),\n    ExitSignal {\n        channel: Uuid,\n        signal_name: Sig,\n        core_dumped: bool,\n        error_message: String,\n        lang_tag: String,\n    },\n    ExtendedData {\n        channel: Uuid,\n        data: Bytes,\n        ext: u32,\n    },\n    ConnectionError(ConnectionError),\n    // ForwardedTCPIP(Uuid, DirectTCPIPParams),\n    Done,\n    HostKeyReceived(PublicKey),\n    HostKeyUnknown(PublicKey, oneshot::Sender<bool>),\n    ForwardedTcpIp(Uuid, ForwardedTcpIpParams),\n    ForwardedStreamlocal(Uuid, ForwardedStreamlocalParams),\n    ForwardedAgent(Uuid),\n    X11(Uuid, String, u32),\n}\n\npub type RCCommandReply = oneshot::Sender<Result<(), SshClientError>>;\n\n#[derive(Clone, Debug)]\npub enum RCCommand {\n    Connect(TargetSSHOptions),\n    Channel(Uuid, ChannelOperation),\n    ForwardTCPIP(String, u32),\n    CancelTCPIPForward(String, u32),\n    StreamlocalForward(String),\n    CancelStreamlocalForward(String),\n    Disconnect,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub enum RCState {\n    NotInitialized,\n    Connecting,\n    Connected,\n    Disconnected,\n}\n\n#[derive(Debug)]\nenum InnerEvent {\n    RCCommand(RCCommand, Option<RCCommandReply>),\n    ClientHandlerEvent(ClientHandlerEvent),\n}\n\npub struct RemoteClient {\n    id: SessionId,\n    tx: UnboundedSender<RCEvent>,\n    session: Option<Arc<Mutex<Handle<ClientHandler>>>>,\n    channel_pipes: Arc<Mutex<HashMap<Uuid, UnboundedSender<ChannelOperation>>>>,\n    pending_ops: Vec<(Uuid, ChannelOperation)>,\n    pending_forwards: Vec<(String, u32)>,\n    pending_streamlocal_forwards: Vec<String>,\n    state: RCState,\n    abort_rx: UnboundedReceiver<()>,\n    inner_event_rx: UnboundedReceiver<InnerEvent>,\n    inner_event_tx: UnboundedSender<InnerEvent>,\n    child_tasks: Vec<JoinHandle<Result<(), SshClientError>>>,\n    services: Services,\n}\n\npub struct RemoteClientHandles {\n    pub event_rx: UnboundedReceiver<RCEvent>,\n    pub command_tx: UnboundedSender<(RCCommand, Option<RCCommandReply>)>,\n    pub abort_tx: UnboundedSender<()>,\n}\n\nimpl RemoteClient {\n    pub fn create(id: SessionId, services: Services) -> io::Result<RemoteClientHandles> {\n        let (event_tx, event_rx) = unbounded_channel();\n        let (command_tx, mut command_rx) = unbounded_channel();\n        let (abort_tx, abort_rx) = unbounded_channel();\n\n        let (inner_event_tx, inner_event_rx) = unbounded_channel();\n\n        let this = Self {\n            id,\n            tx: event_tx,\n            session: None,\n            channel_pipes: Arc::new(Mutex::new(HashMap::new())),\n            pending_ops: vec![],\n            pending_forwards: vec![],\n            pending_streamlocal_forwards: vec![],\n            state: RCState::NotInitialized,\n            inner_event_rx,\n            inner_event_tx: inner_event_tx.clone(),\n            child_tasks: vec![],\n            services,\n            abort_rx,\n        };\n\n        tokio::spawn(\n            {\n                async move {\n                    while let Some((e, response)) = command_rx.recv().await {\n                        inner_event_tx.send(InnerEvent::RCCommand(e, response))?\n                    }\n                    Ok::<(), anyhow::Error>(())\n                }\n            }\n            .instrument(Span::current()),\n        );\n\n        this.start()?;\n\n        Ok(RemoteClientHandles {\n            event_rx,\n            command_tx,\n            abort_tx,\n        })\n    }\n\n    fn set_disconnected(&mut self) {\n        self.session = None;\n        for (id, op) in self.pending_ops.drain(..) {\n            if let ChannelOperation::OpenShell = op {\n                let _ = self.tx.send(RCEvent::Close(id));\n            }\n            if let ChannelOperation::OpenDirectTCPIP { .. } = op {\n                let _ = self.tx.send(RCEvent::Close(id));\n            }\n        }\n        let _ = self.set_state(RCState::Disconnected);\n        let _ = self.tx.send(RCEvent::Done);\n    }\n\n    fn set_state(&mut self, state: RCState) -> Result<(), SshClientError> {\n        self.state = state.clone();\n        self.tx\n            .send(RCEvent::State(state))\n            .map_err(|_| SshClientError::MpscError)?;\n        Ok(())\n    }\n\n    // fn map_channel(&self, ch: &ChannelId) -> Result<Uuid> {\n    //     self.channel_map\n    //         .get_by_left(ch)\n    //         .cloned()\n    //         .ok_or_else(|| anyhow::anyhow!(\"Channel not known\"))\n    // }\n\n    // fn map_channel_reverse(&self, ch: &Uuid) -> Result<ChannelId> {\n    //     self.channel_map\n    //         .get_by_right(ch)\n    //         .cloned()\n    //         .ok_or_else(|| anyhow::anyhow!(\"Channel not known\"))\n    // }\n\n    async fn apply_channel_op(\n        &mut self,\n        channel_id: Uuid,\n        op: ChannelOperation,\n    ) -> Result<(), SshClientError> {\n        if self.state != RCState::Connected {\n            self.pending_ops.push((channel_id, op));\n            return Ok(());\n        }\n\n        match op {\n            ChannelOperation::OpenShell => {\n                self.open_shell(channel_id).await?;\n            }\n            ChannelOperation::OpenDirectTCPIP(params) => {\n                self.open_direct_tcpip(channel_id, params).await?;\n            }\n            ChannelOperation::OpenDirectStreamlocal(path) => {\n                self.open_direct_streamlocal(channel_id, path).await?;\n            }\n            op => {\n                let mut channel_pipes = self.channel_pipes.lock().await;\n                match channel_pipes.get(&channel_id) {\n                    Some(tx) => {\n                        if tx.send(op).is_err() {\n                            channel_pipes.remove(&channel_id);\n                        }\n                    }\n                    None => debug!(channel=%channel_id, \"operation for unknown channel\"),\n                }\n            }\n        }\n        Ok(())\n    }\n\n    pub fn start(mut self) -> io::Result<JoinHandle<anyhow::Result<()>>> {\n        let name = format!(\"SSH {} client commands\", self.id);\n        tokio::task::Builder::new().name(&name).spawn(\n            async move {\n                async {\n                    loop {\n                        tokio::select! {\n                            Some(event) = self.inner_event_rx.recv() => {\n                                debug!(event=?event, \"event\");\n                                if self.handle_event(event).await? {\n                                    break\n                                }\n                            }\n                            Some(_) = self.abort_rx.recv() => {\n                                debug!(\"Abort requested\");\n                                self.disconnect().await;\n                                break\n                            }\n                        };\n                    }\n                    Ok::<(), anyhow::Error>(())\n                }\n                .await\n                .map_err(|error| {\n                    error!(?error, \"error in command loop\");\n                    let err = anyhow::anyhow!(\"Error in command loop: {error}\");\n                    let _ = self.tx.send(RCEvent::Error(error));\n                    err\n                })?;\n                info!(\"Client session closed\");\n                Ok::<(), anyhow::Error>(())\n            }\n            .instrument(Span::current()),\n        )\n    }\n\n    async fn handle_event(&mut self, event: InnerEvent) -> Result<bool> {\n        match event {\n            InnerEvent::RCCommand(cmd, reply) => {\n                let result = self.handle_command(cmd).await;\n                let brk = matches!(result, Ok(true));\n                if let Some(reply) = reply {\n                    let _ = reply.send(result.map(|_| ()));\n                }\n                return Ok(brk);\n            }\n            InnerEvent::ClientHandlerEvent(client_event) => {\n                debug!(\"Client handler event: {:?}\", client_event);\n                match client_event {\n                    ClientHandlerEvent::Disconnect => {\n                        self._on_disconnect().await?;\n                    }\n                    ClientHandlerEvent::ForwardedTcpIp(channel, params) => {\n                        info!(\"New forwarded connection: {params:?}\");\n                        let id = self.setup_server_initiated_channel(channel).await?;\n                        let _ = self.tx.send(RCEvent::ForwardedTcpIp(id, params));\n                    }\n                    ClientHandlerEvent::ForwardedStreamlocal(channel, params) => {\n                        info!(\"New forwarded socket connection: {params:?}\");\n                        let id = self.setup_server_initiated_channel(channel).await?;\n                        let _ = self.tx.send(RCEvent::ForwardedStreamlocal(id, params));\n                    }\n                    ClientHandlerEvent::ForwardedAgent(channel) => {\n                        info!(\"New forwarded agent connection\");\n                        let id = self.setup_server_initiated_channel(channel).await?;\n                        let _ = self.tx.send(RCEvent::ForwardedAgent(id));\n                    }\n                    ClientHandlerEvent::X11(channel, originator_address, originator_port) => {\n                        info!(\"New X11 connection from {originator_address}:{originator_port:?}\");\n                        let id = self.setup_server_initiated_channel(channel).await?;\n                        let _ = self\n                            .tx\n                            .send(RCEvent::X11(id, originator_address, originator_port));\n                    }\n                    event => {\n                        error!(?event, \"Unhandled client handler event\");\n                    }\n                }\n            }\n        }\n        Ok(false)\n    }\n\n    async fn setup_server_initiated_channel(\n        &mut self,\n        channel: russh::Channel<russh::client::Msg>,\n    ) -> Result<Uuid> {\n        let id = Uuid::new_v4();\n\n        let (tx, rx) = unbounded_channel();\n        self.channel_pipes.lock().await.insert(id, tx);\n\n        let session_channel = SessionChannel::new(channel, id, rx, self.tx.clone(), self.id);\n\n        self.child_tasks.push(\n            tokio::task::Builder::new()\n                .name(&format!(\"SSH {} {:?} ops\", self.id, id))\n                .spawn(session_channel.run())?,\n        );\n\n        Ok(id)\n    }\n\n    async fn handle_command(&mut self, cmd: RCCommand) -> Result<bool, SshClientError> {\n        match cmd {\n            RCCommand::Connect(options) => match self.connect(options).await {\n                Ok(_) => {\n                    self.set_state(RCState::Connected)\n                        .map_err(SshClientError::other)?;\n                    let ops = self.pending_ops.drain(..).collect::<Vec<_>>();\n                    for (id, op) in ops {\n                        self.apply_channel_op(id, op).await?;\n                    }\n\n                    let forwards = self.pending_forwards.drain(..).collect::<Vec<_>>();\n                    for (address, port) in forwards {\n                        self.tcpip_forward(address, port).await?;\n                    }\n\n                    let forwards = self\n                        .pending_streamlocal_forwards\n                        .drain(..)\n                        .collect::<Vec<_>>();\n                    for socket_path in forwards {\n                        self.streamlocal_forward(socket_path).await?;\n                    }\n                }\n                Err(e) => {\n                    debug!(\"Connect error: {}\", e);\n                    let _ = self.tx.send(RCEvent::ConnectionError(e));\n\n                    // Allow some time for the SessionServer to process the ConnectionError and print a message to the terminal\n                    // before closing the session. If we don't wait, the session might close too quickly and the user won't see the error.\n                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;\n                    self.set_disconnected();\n\n                    return Ok(true);\n                }\n            },\n            RCCommand::Channel(ch, op) => {\n                self.apply_channel_op(ch, op).await?;\n            }\n            RCCommand::ForwardTCPIP(address, port) => {\n                self.tcpip_forward(address, port).await?;\n            }\n            RCCommand::CancelTCPIPForward(address, port) => {\n                self.cancel_tcpip_forward(address, port).await?;\n            }\n            RCCommand::StreamlocalForward(socket_path) => {\n                self.streamlocal_forward(socket_path).await?;\n            }\n            RCCommand::CancelStreamlocalForward(socket_path) => {\n                self.cancel_streamlocal_forward(socket_path).await?;\n            }\n            RCCommand::Disconnect => {\n                self.disconnect().await;\n                return Ok(true);\n            }\n        }\n        Ok(false)\n    }\n\n    async fn connect(&mut self, ssh_options: TargetSSHOptions) -> Result<(), ConnectionError> {\n        let address_str = format!(\"{}:{}\", ssh_options.host, ssh_options.port);\n        let address = match address_str\n            .to_socket_addrs()\n            .map_err(ConnectionError::Io)\n            .and_then(|mut x| x.next().ok_or(ConnectionError::Resolve))\n        {\n            Ok(address) => address,\n            Err(error) => {\n                error!(?error, address=%address_str, \"Cannot resolve target address\");\n                self.set_disconnected();\n                return Err(error);\n            }\n        };\n\n        info!(?address, username = &ssh_options.username[..], \"Connecting\");\n        let algos = if ssh_options.allow_insecure_algos.unwrap_or(false) {\n            Preferred {\n                kex: Cow::Borrowed(&[\n                    kex::MLKEM768X25519_SHA256,\n                    kex::CURVE25519,\n                    kex::CURVE25519_PRE_RFC_8731,\n                    kex::ECDH_SHA2_NISTP256,\n                    kex::ECDH_SHA2_NISTP384,\n                    kex::ECDH_SHA2_NISTP521,\n                    kex::DH_G16_SHA512,\n                    kex::DH_G14_SHA256, // non-default\n                    kex::DH_GEX_SHA256,\n                    kex::DH_G1_SHA1, // non-default\n                    kex::EXTENSION_SUPPORT_AS_CLIENT,\n                    kex::EXTENSION_SUPPORT_AS_SERVER,\n                    kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,\n                    kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER,\n                ]),\n                key: Cow::Borrowed(&[\n                    russh::keys::Algorithm::Ed25519,\n                    russh::keys::Algorithm::Ecdsa {\n                        curve: russh::keys::EcdsaCurve::NistP256,\n                    },\n                    russh::keys::Algorithm::Ecdsa {\n                        curve: russh::keys::EcdsaCurve::NistP384,\n                    },\n                    russh::keys::Algorithm::Ecdsa {\n                        curve: russh::keys::EcdsaCurve::NistP521,\n                    },\n                    russh::keys::Algorithm::Rsa {\n                        hash: Some(russh::keys::HashAlg::Sha256),\n                    },\n                    russh::keys::Algorithm::Rsa {\n                        hash: Some(russh::keys::HashAlg::Sha512),\n                    },\n                    russh::keys::Algorithm::Rsa { hash: None },\n                ]),\n                cipher: Cow::Borrowed(&[\n                    russh::cipher::CHACHA20_POLY1305,\n                    russh::cipher::AES_256_GCM,\n                    russh::cipher::AES_256_CTR,\n                    russh::cipher::AES_256_CBC,\n                    russh::cipher::AES_192_CTR,\n                    russh::cipher::AES_192_CBC,\n                    russh::cipher::AES_128_CTR,\n                    russh::cipher::AES_128_CBC,\n                    russh::cipher::TRIPLE_DES_CBC,\n                ]),\n                ..<_>::default()\n            }\n        } else {\n            Preferred::default()\n        };\n\n        let mut config = russh::client::Config {\n            preferred: algos,\n            nodelay: true,\n            ..Default::default()\n        };\n        if ssh_options.allow_insecure_algos.unwrap_or(false) {\n            if let Ok(gex) = russh::client::GexParams::new(2048, 2048, 8192) {\n                config.gex = gex;\n            }\n        }\n\n        let config = Arc::new(config);\n\n        let (event_tx, mut event_rx) = unbounded_channel();\n        let handler = ClientHandler {\n            ssh_options: ssh_options.clone(),\n            event_tx,\n            services: self.services.clone(),\n            session_id: self.id,\n        };\n\n        let fut_connect = russh::client::connect(config, address, handler);\n        pin_mut!(fut_connect);\n\n        loop {\n            tokio::select! {\n                Some(event) = event_rx.recv() => {\n                    match event {\n                        ClientHandlerEvent::HostKeyReceived(key) => {\n                            self.tx.send(RCEvent::HostKeyReceived(key)).map_err(|_| ConnectionError::Internal)?;\n                        }\n                        ClientHandlerEvent::HostKeyUnknown(key, reply) => {\n                            self.tx.send(RCEvent::HostKeyUnknown(key, reply)).map_err(|_| ConnectionError::Internal)?;\n                        }\n                        _ => {}\n                    }\n                }\n                Some(_) = self.abort_rx.recv() => {\n                    info!(\"Abort requested\");\n                    self.set_disconnected();\n                    return Err(ConnectionError::Aborted)\n                }\n                session = &mut fut_connect => {\n                    let mut session = match session {\n                        Ok(session) => session,\n                        Err(error) => {\n                            let connection_error = match error {\n                                ClientHandlerError::ConnectionError(e) => e,\n                                ClientHandlerError::Ssh(e) => ConnectionError::Ssh(e),\n                                ClientHandlerError::Internal => ConnectionError::Internal,\n                            };\n                            error!(error=?connection_error, \"Connection error\");\n                            return Err(connection_error);\n                        }\n                    };\n\n                    let mut auth_result = false;\n                    let mut auth_error_msg: Option<String> = None;\n                    match ssh_options.auth {\n                        SSHTargetAuth::Password(auth) => {\n                            let response = session\n                                    .authenticate_password(\n                                        ssh_options.username.clone(),\n                                        auth.password.expose_secret()\n                                    )\n                                    .await?;\n                            auth_result = self._handle_auth_result(\n                                &mut session,\n                                ssh_options.username.clone(),\n                                response\n                            ).await.unwrap_or(false);\n                            if auth_result {\n                                debug!(username=&ssh_options.username[..], \"Authenticated with password\");\n                            } else {\n                                auth_error_msg = Some(\"Password authentication was rejected by the SSH target\".to_string());\n                            }\n                        }\n                        SSHTargetAuth::PublicKey(_) => {\n                            let best_hash = session.best_supported_rsa_hash().await?.flatten();\n                            #[allow(clippy::explicit_auto_deref)]\n                            let keys = load_keys(\n                                &*self.services.config.lock().await,\n                                &self.services.global_params,\n                                \"client\"\n                            )?;\n                            let allow_insecure_algos = ssh_options.allow_insecure_algos.unwrap_or(false);\n                            for key in keys.into_iter() {\n                                let key = Arc::new(key);\n                                if key.key_data().is_rsa() && best_hash.is_none() && !allow_insecure_algos {\n                                    info!(\"Skipping ssh-rsa (SHA1) key authentication since insecure SSH algos are not allowed for this target\");\n                                    continue;\n                                }\n                                let key_str = key.public_key().to_openssh().map_err(russh::Error::from)?;\n                                let mut response  = session\n                                    .authenticate_publickey(\n                                        ssh_options.username.clone(),\n                                        PrivateKeyWithHashAlg::new(key.clone(), best_hash),\n                                    )\n                                    .await?;\n\n                                auth_result = self._handle_auth_result(\n                                    &mut session,\n                                    ssh_options.username.clone(),\n                                    response\n                                ).await.unwrap_or(false);\n\n                                if !auth_result && key.key_data().is_rsa() && best_hash.is_some() && allow_insecure_algos {\n                                    // Corner case: OpenSSH advertising rsa2-sha-* through server-sig-algs, but it being\n                                    // disabled via PubkeyAcceptedAlgorithms. So far the only case is our own test suite.\n                                    // In this case we retry with ssh-rsa (SHA1)\n                                    response = session\n                                        .authenticate_publickey(\n                                            ssh_options.username.clone(),\n                                            PrivateKeyWithHashAlg::new(key.clone(), None),\n                                        ).await?;\n\n                                    auth_result = self._handle_auth_result(\n                                        &mut session,\n                                        ssh_options.username.clone(),\n                                        response\n                                    ).await.unwrap_or(false);\n                                }\n\n                                if auth_result {\n                                    debug!(username=&ssh_options.username[..], key=%key_str, \"Authenticated with key\");\n                                    break;\n                                } else {\n                                    auth_error_msg = Some(\"Public key authentication was rejected by the SSH target\".into());\n                                }\n                            }\n                        }\n                    }\n\n                    if !auth_result {\n                        let reason = auth_error_msg.unwrap_or_else(|| \"Authentication was rejected by the SSH target\".to_string());\n                        error!(%reason, \"Warpgate could not authenticate with SSH target\");\n                        let _ = session\n                            .disconnect(russh::Disconnect::ByApplication, \"\", \"\")\n                            .await;\n                        return Err(ConnectionError::Authentication);\n                    }\n\n                    self.session = Some(Arc::new(Mutex::new(session)));\n\n                    info!(?address, \"Connected\");\n\n                    tokio::spawn({\n                        let inner_event_tx = self.inner_event_tx.clone();\n                        async move {\n                            while let Some(e) = event_rx.recv().await {\n                                info!(\"{:?}\", e);\n                                inner_event_tx.send(InnerEvent::ClientHandlerEvent(e))?\n                            }\n                            Ok::<(), anyhow::Error>(())\n                        }\n                    }.instrument(Span::current()));\n\n                    return Ok(())\n                }\n            }\n        }\n    }\n\n    /// Handles an AuthResult from a password or public key authentication attempt.\n    /// If presented with an additional keyboard-interactive challenge it will respond with empty\n    /// strings. This ensures optional 2fa is respected, where this extra challenge always happens.\n    ///\n    /// TODO: Optionally implement forwarding the challenges to the user\n    ///\n    /// # Arguments\n    ///\n    /// * `session`: the session for which the initial result is\n    /// * `username`: username of the authenticating user\n    /// * `result`: the initial result received via the configured auth method\n    async fn _handle_auth_result(\n        &self,\n        session: &mut Handle<ClientHandler>,\n        username: String,\n        result: AuthResult,\n    ) -> Result<bool> {\n        debug!(\"Handling AuthResult\");\n        match result {\n            AuthResult::Success => {\n                debug!(\"AuthResult is already success, no further handling needed\");\n                return Ok(true);\n            }\n            AuthResult::Failure {\n                remaining_methods: methods,\n                ..\n            } => {\n                debug!(\"Initial auth failed, checking remaining methods\");\n                for method in methods.iter() {\n                    if matches!(method, MethodKind::KeyboardInteractive) {\n                        debug!(\"Found keyboard-interactive challenge\");\n                        let mut kb_result = session\n                            .authenticate_keyboard_interactive_start(username.clone(), None)\n                            .await?;\n\n                        while let KeyboardInteractiveAuthResponse::InfoRequest {\n                            name: _name,\n                            instructions: _instructions,\n                            prompts,\n                        } = kb_result\n                        {\n                            for prompt in prompts.iter().clone() {\n                                debug!(\n                                    prompt = prompt.prompt,\n                                    echo = prompt.echo,\n                                    \"Prompt received for keyboard-interactive\"\n                                );\n                            }\n                            debug!(\"Responding with empty responses\");\n                            kb_result = session\n                                .authenticate_keyboard_interactive_respond(vec![\n                                    String::new();\n                                    prompts.len()\n                                ])\n                                .await?;\n                        }\n\n                        match kb_result {\n                            KeyboardInteractiveAuthResponse::Success => {\n                                debug!(\"keyboard-interactive challenge successful\");\n                                return Ok(true);\n                            }\n                            KeyboardInteractiveAuthResponse::Failure {\n                                remaining_methods: _remaining_methods,\n                                ..\n                            } => {\n                                debug!(\"keyboard-interactive challenge failed\");\n                                return Ok(false);\n                            }\n                            _ => {}\n                        }\n                    }\n                    continue;\n                }\n            }\n        }\n        Ok(false)\n    }\n\n    async fn open_shell(&mut self, channel_id: Uuid) -> Result<(), SshClientError> {\n        if let Some(session) = &self.session {\n            let session = session.lock().await;\n            let channel = session.channel_open_session().await?;\n\n            let (tx, rx) = unbounded_channel();\n            self.channel_pipes.lock().await.insert(channel_id, tx);\n\n            let channel = SessionChannel::new(channel, channel_id, rx, self.tx.clone(), self.id);\n            self.child_tasks.push(\n                tokio::task::Builder::new()\n                    .name(&format!(\"SSH {} {:?} ops\", self.id, channel_id))\n                    .spawn(channel.run())\n                    .map_err(|e| SshClientError::Other(Box::new(e)))?,\n            );\n        }\n        Ok(())\n    }\n\n    async fn open_direct_tcpip(\n        &mut self,\n        channel_id: Uuid,\n        params: DirectTCPIPParams,\n    ) -> Result<(), SshClientError> {\n        if let Some(session) = &self.session {\n            let session = session.lock().await;\n            let channel = session\n                .channel_open_direct_tcpip(\n                    params.host_to_connect,\n                    params.port_to_connect,\n                    params.originator_address,\n                    params.originator_port,\n                )\n                .await?;\n\n            let (tx, rx) = unbounded_channel();\n            self.channel_pipes.lock().await.insert(channel_id, tx);\n\n            let channel =\n                DirectTCPIPChannel::new(channel, channel_id, rx, self.tx.clone(), self.id);\n            self.child_tasks.push(\n                tokio::task::Builder::new()\n                    .name(&format!(\"SSH {} {:?} ops\", self.id, channel_id))\n                    .spawn(channel.run())\n                    .map_err(|e| SshClientError::Other(Box::new(e)))?,\n            );\n        }\n        Ok(())\n    }\n\n    async fn open_direct_streamlocal(\n        &mut self,\n        channel_id: Uuid,\n        path: String,\n    ) -> Result<(), SshClientError> {\n        if let Some(session) = &self.session {\n            let session = session.lock().await;\n            let channel = session.channel_open_direct_streamlocal(path).await?;\n\n            let (tx, rx) = unbounded_channel();\n            self.channel_pipes.lock().await.insert(channel_id, tx);\n\n            let channel =\n                DirectTCPIPChannel::new(channel, channel_id, rx, self.tx.clone(), self.id);\n            self.child_tasks.push(\n                tokio::task::Builder::new()\n                    .name(&format!(\"SSH {} {:?} ops\", self.id, channel_id))\n                    .spawn(channel.run())\n                    .map_err(|e| SshClientError::Other(Box::new(e)))?,\n            );\n        }\n        Ok(())\n    }\n\n    async fn tcpip_forward(&mut self, address: String, port: u32) -> Result<(), SshClientError> {\n        if let Some(session) = &self.session {\n            let session = session.lock().await;\n            session.tcpip_forward(address, port).await?;\n        } else {\n            self.pending_forwards.push((address, port));\n        }\n        Ok(())\n    }\n\n    async fn cancel_tcpip_forward(\n        &mut self,\n        address: String,\n        port: u32,\n    ) -> Result<(), SshClientError> {\n        if let Some(session) = &self.session {\n            let session = session.lock().await;\n            session.cancel_tcpip_forward(address, port).await?;\n        } else {\n            self.pending_forwards\n                .retain(|x| x.0 != address || x.1 != port);\n        }\n        Ok(())\n    }\n\n    async fn streamlocal_forward(&mut self, socket_path: String) -> Result<(), SshClientError> {\n        if let Some(session) = &self.session {\n            let session = session.lock().await;\n            session.streamlocal_forward(socket_path).await?;\n        } else {\n            self.pending_streamlocal_forwards.push(socket_path);\n        }\n        Ok(())\n    }\n\n    async fn cancel_streamlocal_forward(\n        &mut self,\n        socket_path: String,\n    ) -> Result<(), SshClientError> {\n        if let Some(session) = &self.session {\n            let session = session.lock().await;\n            session.cancel_streamlocal_forward(socket_path).await?;\n        } else {\n            self.pending_streamlocal_forwards\n                .retain(|x| x != &socket_path);\n        }\n        Ok(())\n    }\n\n    async fn disconnect(&mut self) {\n        if let Some(session) = &mut self.session {\n            let _ = session\n                .lock()\n                .await\n                .disconnect(russh::Disconnect::ByApplication, \"\", \"\")\n                .await;\n            self.set_disconnected();\n        }\n    }\n\n    async fn _on_disconnect(&mut self) -> Result<()> {\n        self.set_disconnected();\n        Ok(())\n    }\n}\n\nimpl Drop for RemoteClient {\n    fn drop(&mut self) {\n        for task in self.child_tasks.drain(..) {\n            task.abort();\n        }\n        info!(\"Closed connection\");\n        debug!(\"Dropped\");\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/common.rs",
    "content": "use std::fmt::{Display, Formatter};\n\nuse bytes::Bytes;\nuse russh::{ChannelId, Pty, Sig};\nuse serde::{Deserialize, Serialize};\n\n#[derive(Clone, Debug)]\npub struct PtyRequest {\n    pub term: String,\n    pub col_width: u32,\n    pub row_height: u32,\n    pub pix_width: u32,\n    pub pix_height: u32,\n    pub modes: Vec<(Pty, u32)>,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq)]\npub struct ServerChannelId(pub ChannelId);\n\nimpl Display for ServerChannelId {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.0)\n    }\n}\n\n#[derive(Clone, Debug)]\npub struct DirectTCPIPParams {\n    pub host_to_connect: String,\n    pub port_to_connect: u32,\n    pub originator_address: String,\n    pub originator_port: u32,\n}\n\n#[derive(Clone, Debug)]\npub struct ForwardedTcpIpParams {\n    pub connected_address: String,\n    pub connected_port: u32,\n    pub originator_address: String,\n    pub originator_port: u32,\n}\n\n#[derive(Clone, Debug)]\npub struct ForwardedStreamlocalParams {\n    pub socket_path: String,\n}\n\n#[derive(Clone, Debug)]\npub struct X11Request {\n    pub single_conection: bool,\n    pub x11_auth_protocol: String,\n    pub x11_auth_cookie: String,\n    pub x11_screen_number: u32,\n}\n\n#[derive(Clone, Debug)]\npub enum ChannelOperation {\n    OpenShell,\n    OpenDirectTCPIP(DirectTCPIPParams),\n    OpenDirectStreamlocal(String),\n    OpenX11(String, u32),\n    RequestPty(PtyRequest),\n    ResizePty(PtyRequest),\n    RequestShell,\n    RequestEnv(String, String),\n    RequestExec(String),\n    RequestX11(X11Request),\n    AgentForward,\n    RequestSubsystem(String),\n    Data(Bytes),\n    ExtendedData { data: Bytes, ext: u32 },\n    Close,\n    Eof,\n    Signal(Sig),\n}\n\n#[derive(Serialize, Deserialize, Debug)]\n#[serde(tag = \"type\")]\npub enum SshRecordingMetadata {\n    #[serde(rename = \"ssh-shell\")]\n    Shell { channel: usize },\n    #[serde(rename = \"ssh-exec\")]\n    Exec { channel: usize },\n    #[serde(rename = \"ssh-direct-tcpip\")]\n    DirectTcpIp { host: String, port: u16 },\n    #[serde(rename = \"ssh-direct-socket\")]\n    DirectSocket { path: String },\n    #[serde(rename = \"ssh-forwarded-tcpip\")]\n    ForwardedTcpIp { host: String, port: u16 },\n    #[serde(rename = \"ssh-forwarded-socket\")]\n    ForwardedSocket { path: String },\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/compat.rs",
    "content": "use std::fmt::Display;\n\npub trait ContextExt<T, C> {\n    fn context(self, context: C) -> anyhow::Result<T>;\n}\n\nimpl<T, C> ContextExt<T, C> for Result<T, ()>\nwhere\n    C: Display + Send + Sync + 'static,\n{\n    fn context(self, context: C) -> anyhow::Result<T> {\n        self.map_err(|_| anyhow::anyhow!(\"unspecified error\").context(context))\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/keys.rs",
    "content": "use std::fs::{create_dir_all, File};\nuse std::path::PathBuf;\n\nuse anyhow::{Context, Result};\nuse russh::keys::{encode_pkcs8_pem, load_secret_key, HashAlg, PrivateKey};\nuse tracing::*;\nuse warpgate_common::helpers::fs::{secure_directory, secure_file};\nuse warpgate_common::helpers::rng::get_crypto_rng;\nuse warpgate_common::{GlobalParams, WarpgateConfig};\n\nfn get_keys_path(config: &WarpgateConfig, params: &GlobalParams) -> PathBuf {\n    let mut path = params.paths_relative_to().clone();\n    path.push(&config.store.ssh.keys);\n    path\n}\n\npub fn generate_keys(config: &WarpgateConfig, params: &GlobalParams, prefix: &str) -> Result<()> {\n    let path = get_keys_path(config, params);\n    create_dir_all(&path)?;\n    if params.should_secure_files() {\n        secure_directory(&path)?;\n    }\n\n    for (algo, name) in [\n        (russh::keys::Algorithm::Ed25519, format!(\"{prefix}-ed25519\")),\n        (\n            russh::keys::Algorithm::Rsa {\n                hash: Some(HashAlg::Sha512),\n            },\n            format!(\"{prefix}-rsa\"),\n        ),\n    ] {\n        let key_path = path.join(name);\n        if !key_path.exists() {\n            info!(\"Generating {prefix} key ({algo:?})\");\n            let key = PrivateKey::random(&mut get_crypto_rng(), algo)\n                .context(\"Failed to generate key\")?;\n            let f = File::create(&key_path)?;\n            encode_pkcs8_pem(&key, f)?;\n        }\n        if params.should_secure_files() {\n            secure_file(&key_path)?;\n        }\n    }\n\n    Ok(())\n}\n\npub fn load_keys(\n    config: &WarpgateConfig,\n    params: &GlobalParams,\n    prefix: &str,\n) -> Result<Vec<PrivateKey>, russh::keys::Error> {\n    let path = get_keys_path(config, params);\n    Ok(vec![\n        load_secret_key(path.join(format!(\"{prefix}-ed25519\")), None)?,\n        load_secret_key(path.join(format!(\"{prefix}-rsa\")), None)?,\n    ])\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/known_hosts.rs",
    "content": "use std::sync::Arc;\n\nuse russh::keys::{PublicKey, PublicKeyBase64};\nuse sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};\nuse tokio::sync::Mutex;\nuse uuid::Uuid;\nuse warpgate_db_entities::KnownHost;\n\npub struct KnownHosts {\n    db: Arc<Mutex<DatabaseConnection>>,\n}\n\npub enum KnownHostValidationResult {\n    Valid,\n    Invalid {\n        key_type: String,\n        key_base64: String,\n    },\n    Unknown,\n}\n\nimpl KnownHosts {\n    pub fn new(db: &Arc<Mutex<DatabaseConnection>>) -> Self {\n        Self { db: db.clone() }\n    }\n\n    pub async fn validate(\n        &mut self,\n        host: &str,\n        port: u16,\n        key: &PublicKey,\n    ) -> Result<KnownHostValidationResult, sea_orm::DbErr> {\n        let db = self.db.lock().await;\n        let entries = KnownHost::Entity::find()\n            .filter(KnownHost::Column::Host.eq(host))\n            .filter(KnownHost::Column::Port.eq(port))\n            .filter(KnownHost::Column::KeyType.eq(key.algorithm().as_str()))\n            .all(&*db)\n            .await?;\n\n        let key_base64 = key.public_key_base64();\n        if entries.iter().any(|x| x.key_base64 == key_base64) {\n            return Ok(KnownHostValidationResult::Valid);\n        }\n        if let Some(first) = entries.first() {\n            return Ok(KnownHostValidationResult::Invalid {\n                key_type: first.key_type.clone(),\n                key_base64: first.key_base64.clone(),\n            });\n        }\n        Ok(KnownHostValidationResult::Unknown)\n    }\n\n    pub async fn trust(\n        &mut self,\n        host: &str,\n        port: u16,\n        key: &PublicKey,\n    ) -> Result<(), sea_orm::DbErr> {\n        use sea_orm::ActiveValue::Set;\n\n        let values = KnownHost::ActiveModel {\n            id: Set(Uuid::new_v4()),\n            host: Set(host.to_owned()),\n            port: Set(port.into()),\n            key_type: Set(key.algorithm().to_string()),\n            key_base64: Set(key.public_key_base64()),\n        };\n\n        let db = self.db.lock().await;\n        values.insert(&*db).await?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/lib.rs",
    "content": "mod client;\nmod common;\nmod compat;\nmod keys;\npub mod known_hosts;\nmod server;\nuse std::fmt::Debug;\n\nuse anyhow::Result;\npub use client::*;\npub use common::*;\npub use keys::*;\npub use server::run_server;\nuse warpgate_common::{ListenEndpoint, ProtocolName};\nuse warpgate_core::{ProtocolServer, Services};\n\npub static PROTOCOL_NAME: ProtocolName = \"SSH\";\n\n#[derive(Clone)]\npub struct SSHProtocolServer {\n    services: Services,\n}\n\nimpl SSHProtocolServer {\n    pub async fn new(services: &Services) -> Result<Self> {\n        let config = services.config.lock().await;\n        generate_keys(&config, &services.global_params, \"host\")?;\n        generate_keys(&config, &services.global_params, \"client\")?;\n        Ok(SSHProtocolServer {\n            services: services.clone(),\n        })\n    }\n}\n\nimpl ProtocolServer for SSHProtocolServer {\n    async fn run(self, address: ListenEndpoint) -> Result<()> {\n        run_server(self.services, address).await\n    }\n\n    fn name(&self) -> &'static str {\n        \"SSH\"\n    }\n}\n\nimpl Debug for SSHProtocolServer {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"SSHProtocolServer\").finish()\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/server/channel_writer.rs",
    "content": "use russh::server::Handle;\nuse russh::ChannelId;\nuse tokio::sync::mpsc;\n\n#[derive(Debug)]\nenum ChannelWriteOperation {\n    Data(Handle, ChannelId, Vec<u8>),\n    ExtendedData(Handle, ChannelId, u32, Vec<u8>),\n    Flush(tokio::sync::oneshot::Sender<()>),\n}\n\n/// Sequences data writes and runs them in background to avoid lockups\npub struct ChannelWriter {\n    tx: mpsc::UnboundedSender<ChannelWriteOperation>,\n}\n\nimpl ChannelWriter {\n    pub fn new() -> Self {\n        let (tx, mut rx) = mpsc::unbounded_channel::<ChannelWriteOperation>();\n        tokio::spawn(async move {\n            while let Some(operation) = rx.recv().await {\n                match operation {\n                    ChannelWriteOperation::Data(handle, channel, data) => {\n                        let _ = handle.data(channel, data).await;\n                    }\n                    ChannelWriteOperation::ExtendedData(handle, channel, ext, data) => {\n                        let _ = handle.extended_data(channel, ext, data).await;\n                    }\n                    ChannelWriteOperation::Flush(reply) => {\n                        let _ = reply.send(());\n                    }\n                }\n            }\n        });\n        ChannelWriter { tx }\n    }\n\n    pub fn write<D: Into<Vec<u8>>>(&self, handle: Handle, channel: ChannelId, data: D) {\n        let _ = self\n            .tx\n            .send(ChannelWriteOperation::Data(handle, channel, data.into()));\n    }\n\n    pub fn write_extended<D: Into<Vec<u8>>>(\n        &self,\n        handle: Handle,\n        channel: ChannelId,\n        ext: u32,\n        data: D,\n    ) {\n        let _ = self.tx.send(ChannelWriteOperation::ExtendedData(\n            handle,\n            channel,\n            ext,\n            data.into(),\n        ));\n    }\n\n    /// Flush all pending writes. Returns when all previously queued operations have completed.\n    pub async fn flush(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {\n        let (tx, rx) = tokio::sync::oneshot::channel();\n        self.tx\n            .send(ChannelWriteOperation::Flush(tx))\n            .map_err(|_| \"ChannelWriter task has stopped\")?;\n        rx.await.map_err(|_| \"ChannelWriter flush failed\")?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/server/mod.rs",
    "content": "mod channel_writer;\nmod russh_handler;\nmod service_output;\nmod session;\nmod session_handle;\nuse std::borrow::Cow;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse anyhow::{Context, Result};\nuse futures::TryStreamExt;\nuse russh::keys::{Algorithm, HashAlg, PrivateKey};\nuse russh::{MethodKind, MethodSet, Preferred};\npub use russh_handler::ServerHandler;\npub use session::ServerSession;\nuse tokio::io::{AsyncRead, AsyncWrite};\nuse tokio::net::TcpStream;\nuse tokio::sync::mpsc::unbounded_channel;\nuse tracing::*;\nuse warpgate_common::ListenEndpoint;\nuse warpgate_core::{Services, SessionStateInit, State};\nuse warpgate_db_entities::Parameters;\n\nuse crate::keys::load_keys;\nuse crate::server::session_handle::SSHSessionHandle;\n\n#[derive(Clone)]\nstruct RusshConfigInit {\n    keys: Vec<PrivateKey>,\n}\n\npub async fn run_server(services: Services, address: ListenEndpoint) -> Result<()> {\n    let russh_config_init = Arc::new({\n        let config = services.config.lock().await;\n        RusshConfigInit {\n            keys: load_keys(&config, &services.global_params, \"host\")?,\n        }\n    });\n\n    let mut listener = address.tcp_accept_stream().await?;\n\n    while let Some(stream) = listener.try_next().await.context(\"accepting connection\")? {\n        let russh_config_init = russh_config_init.clone();\n        let services = services.clone();\n\n        tokio::task::Builder::new()\n            .name(\"SSH new connection setup\")\n            .spawn(async move {\n                if let Err(e) = _handle_connection(services, russh_config_init, stream).await {\n                    error!(%e, \"Connection handling failed\");\n                }\n            })?;\n    }\n    Ok(())\n}\n\nasync fn _handle_connection(\n    services: Services,\n    russh_config_init: Arc<RusshConfigInit>,\n    stream: TcpStream,\n) -> Result<()> {\n    stream.set_nodelay(true)?;\n\n    let remote_address = stream.peer_addr().context(\"getting peer address\")?;\n\n    let (session_handle, session_handle_rx) = SSHSessionHandle::new();\n\n    let server_handle = State::register_session(\n        &services.state,\n        &crate::PROTOCOL_NAME,\n        SessionStateInit {\n            remote_address: Some(remote_address),\n            handle: Box::new(session_handle),\n        },\n    )\n    .await\n    .context(\"registering session\")?;\n\n    let id = server_handle.lock().await.id();\n\n    let (event_tx, event_rx) = unbounded_channel();\n\n    let handler = ServerHandler { event_tx };\n    let wrapped_stream = server_handle.lock().await.wrap_stream(stream).await?;\n\n    let session = match ServerSession::start(\n        remote_address,\n        &services,\n        server_handle,\n        session_handle_rx,\n        event_rx,\n    )\n    .await\n    {\n        Ok(session) => session,\n        Err(error) => {\n            error!(%error, \"Error setting up session\");\n            return Err(error);\n        }\n    };\n\n    let russh_config = {\n        let config = services.config.lock().await;\n\n        russh::server::Config {\n            auth_rejection_time: Duration::from_secs(1),\n            auth_rejection_time_initial: Some(Duration::from_secs(0)),\n            inactivity_timeout: Some(config.store.ssh.inactivity_timeout),\n            keepalive_interval: config.store.ssh.keepalive_interval,\n            methods: get_allowed_auth_methods(&services).await?,\n            keys: russh_config_init.keys.clone(),\n            event_buffer_size: 100,\n            nodelay: true,\n            preferred: Preferred {\n                key: Cow::Borrowed(&[\n                    Algorithm::Ed25519,\n                    Algorithm::Rsa {\n                        hash: Some(HashAlg::Sha512),\n                    },\n                    Algorithm::Rsa {\n                        hash: Some(HashAlg::Sha256),\n                    },\n                    Algorithm::Rsa { hash: None },\n                ]),\n                ..<_>::default()\n            },\n            ..<_>::default()\n        }\n    };\n\n    let russh_config = Arc::new(russh_config);\n\n    tokio::task::Builder::new()\n        .name(&format!(\"SSH {id} session\"))\n        .spawn(session)?;\n\n    tokio::task::Builder::new()\n        .name(&format!(\"SSH {id} protocol\"))\n        .spawn(_run_stream(russh_config, wrapped_stream, handler))?;\n\n    Ok(())\n}\n\nasync fn _run_stream<R>(\n    config: Arc<russh::server::Config>,\n    socket: R,\n    handler: ServerHandler,\n) -> Result<()>\nwhere\n    R: AsyncRead + AsyncWrite + Unpin + Send + 'static,\n{\n    let ret = async move {\n        let session = russh::server::run_stream(config, socket, handler).await?;\n        session.await?;\n        Ok(())\n    }\n    .await;\n\n    if let Err(ref error) = ret {\n        error!(%error, \"Session failed\");\n    }\n\n    ret\n}\n\npub(crate) async fn get_allowed_auth_methods(services: &Services) -> Result<MethodSet> {\n    let parameters = {\n        let db = services.db.lock().await;\n        Parameters::Entity::get(&db).await?\n    };\n\n    let mut methods_vec: Vec<MethodKind> = Vec::new();\n    if parameters.ssh_client_auth_publickey {\n        methods_vec.push(MethodKind::PublicKey);\n    }\n    if parameters.ssh_client_auth_password {\n        methods_vec.push(MethodKind::Password);\n    }\n    if parameters.ssh_client_auth_keyboard_interactive {\n        methods_vec.push(MethodKind::KeyboardInteractive);\n    }\n\n    if methods_vec.is_empty() {\n        warn!(\"All SSH authentication methods are disabled in parameters. Enabling all methods as fallback.\");\n    }\n\n    Ok(MethodSet::from(&methods_vec[..]))\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/server/russh_handler.rs",
    "content": "use std::fmt::Debug;\n\nuse bytes::Bytes;\nuse russh::keys::PublicKey;\nuse russh::server::{Auth, Handle, Msg, Session};\nuse russh::{Channel, ChannelId, Pty, Sig};\nuse tokio::sync::mpsc::UnboundedSender;\nuse tokio::sync::oneshot;\nuse tracing::*;\nuse warpgate_common::Secret;\n\nuse crate::common::{PtyRequest, ServerChannelId};\nuse crate::{DirectTCPIPParams, X11Request};\n\npub struct HandleWrapper(pub Handle);\n\nimpl Debug for HandleWrapper {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"HandleWrapper\")\n    }\n}\n\n#[derive(Debug)]\npub enum ServerHandlerEvent {\n    Authenticated(HandleWrapper),\n    ChannelOpenSession(ServerChannelId, oneshot::Sender<bool>),\n    SubsystemRequest(ServerChannelId, String, oneshot::Sender<bool>),\n    PtyRequest(ServerChannelId, PtyRequest, oneshot::Sender<()>),\n    ShellRequest(ServerChannelId, oneshot::Sender<bool>),\n    AuthPublicKey(Secret<String>, PublicKey, oneshot::Sender<Auth>),\n    AuthPublicKeyOffer(Secret<String>, PublicKey, oneshot::Sender<Auth>),\n    AuthPassword(Secret<String>, Secret<String>, oneshot::Sender<Auth>),\n    AuthKeyboardInteractive(\n        Secret<String>,\n        Option<Secret<String>>,\n        oneshot::Sender<Auth>,\n    ),\n    Data(ServerChannelId, Bytes, oneshot::Sender<()>),\n    ExtendedData(ServerChannelId, Bytes, u32, oneshot::Sender<()>),\n    ChannelClose(ServerChannelId, oneshot::Sender<()>),\n    ChannelEof(ServerChannelId, oneshot::Sender<()>),\n    WindowChangeRequest(ServerChannelId, PtyRequest, oneshot::Sender<()>),\n    Signal(ServerChannelId, Sig, oneshot::Sender<()>),\n    ExecRequest(ServerChannelId, Bytes, oneshot::Sender<bool>),\n    ChannelOpenDirectTcpIp(ServerChannelId, DirectTCPIPParams, oneshot::Sender<bool>),\n    ChannelOpenDirectStreamlocal(ServerChannelId, String, oneshot::Sender<bool>),\n    EnvRequest(ServerChannelId, String, String, oneshot::Sender<()>),\n    X11Request(ServerChannelId, X11Request, oneshot::Sender<()>),\n    TcpIpForward(String, u32, oneshot::Sender<bool>),\n    CancelTcpIpForward(String, u32, oneshot::Sender<bool>),\n    StreamlocalForward(String, oneshot::Sender<bool>),\n    CancelStreamlocalForward(String, oneshot::Sender<bool>),\n    AgentForward(ServerChannelId, oneshot::Sender<bool>),\n    Disconnect,\n}\n\npub struct ServerHandler {\n    pub event_tx: UnboundedSender<ServerHandlerEvent>,\n}\n\n#[derive(thiserror::Error, Debug)]\npub enum ServerHandlerError {\n    #[error(\"channel disconnected\")]\n    ChannelSend,\n}\n\nimpl ServerHandler {\n    fn send_event(&self, event: ServerHandlerEvent) -> Result<(), ServerHandlerError> {\n        self.event_tx\n            .send(event)\n            .map_err(|_| ServerHandlerError::ChannelSend)\n    }\n}\n\nimpl russh::server::Handler for ServerHandler {\n    type Error = anyhow::Error;\n\n    async fn auth_succeeded(&mut self, session: &mut Session) -> Result<(), Self::Error> {\n        let handle = session.handle();\n        self.send_event(ServerHandlerEvent::Authenticated(HandleWrapper(handle)))?;\n        Ok(())\n    }\n\n    async fn channel_open_session(\n        &mut self,\n        channel: Channel<Msg>,\n        _session: &mut Session,\n    ) -> Result<bool, Self::Error> {\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::ChannelOpenSession(\n            ServerChannelId(channel.id()),\n            tx,\n        ))?;\n\n        let allowed = rx.await.unwrap_or(false);\n        Ok(allowed)\n    }\n\n    async fn subsystem_request(\n        &mut self,\n        channel: ChannelId,\n        name: &str,\n        session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let name = name.to_string();\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::SubsystemRequest(\n            ServerChannelId(channel),\n            name,\n            tx,\n        ))?;\n\n        if rx.await.unwrap_or(false) {\n            session.channel_success(channel)?\n        } else {\n            session.channel_failure(channel)?\n        }\n\n        Ok(())\n    }\n\n    async fn pty_request(\n        &mut self,\n        channel: ChannelId,\n        term: &str,\n        col_width: u32,\n        row_height: u32,\n        pix_width: u32,\n        pix_height: u32,\n        modes: &[(Pty, u32)],\n        session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let term = term.to_string();\n        let modes = modes\n            .iter()\n            .take_while(|x| (x.0 as u8) > 0 && (x.0 as u8) < 160)\n            .map(Clone::clone)\n            .collect();\n\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::PtyRequest(\n            ServerChannelId(channel),\n            PtyRequest {\n                term,\n                col_width,\n                row_height,\n                pix_width,\n                pix_height,\n                modes,\n            },\n            tx,\n        ))?;\n\n        let _ = rx.await;\n        session.channel_success(channel)?;\n        Ok(())\n    }\n\n    async fn shell_request(\n        &mut self,\n        channel: ChannelId,\n        session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::ShellRequest(\n            ServerChannelId(channel),\n            tx,\n        ))?;\n\n        if rx.await.unwrap_or(false) {\n            session.channel_success(channel)?\n        } else {\n            session.channel_failure(channel)?\n        }\n\n        Ok(())\n    }\n\n    async fn auth_publickey_offered(\n        &mut self,\n        user: &str,\n        key: &russh::keys::PublicKey,\n    ) -> Result<Auth, Self::Error> {\n        let user = Secret::new(user.to_string());\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::AuthPublicKeyOffer(\n            user,\n            key.clone(),\n            tx,\n        ))?;\n\n        Ok(rx.await.unwrap_or(Auth::reject()))\n    }\n\n    async fn auth_publickey(\n        &mut self,\n        user: &str,\n        key: &russh::keys::PublicKey,\n    ) -> Result<Auth, Self::Error> {\n        let user = Secret::new(user.to_string());\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::AuthPublicKey(user, key.clone(), tx))?;\n\n        let result = rx.await.unwrap_or(Auth::UnsupportedMethod);\n        Ok(result)\n    }\n\n    async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {\n        let user = Secret::new(user.to_string());\n        let password = Secret::new(password.to_string());\n\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::AuthPassword(user, password, tx))?;\n\n        let result = rx.await.unwrap_or(Auth::UnsupportedMethod);\n        Ok(result)\n    }\n\n    async fn auth_keyboard_interactive<'a>(\n        &'a mut self,\n        user: &str,\n        _submethods: &str,\n        response: Option<russh::server::Response<'a>>,\n    ) -> Result<Auth, Self::Error> {\n        let user = Secret::new(user.to_string());\n        let response = response\n            .and_then(|mut r| r.next())\n            .and_then(|b| String::from_utf8(b.to_vec()).ok())\n            .map(Secret::new);\n\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::AuthKeyboardInteractive(\n            user, response, tx,\n        ))?;\n\n        let result = rx.await.unwrap_or(Auth::UnsupportedMethod);\n        Ok(result)\n    }\n\n    async fn data(\n        &mut self,\n        channel: ChannelId,\n        data: &[u8],\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let channel = ServerChannelId(channel);\n        let data = Bytes::from(data.to_vec());\n\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::Data(channel, data, tx))?;\n\n        let _ = rx.await;\n        Ok(())\n    }\n\n    async fn extended_data(\n        &mut self,\n        channel: ChannelId,\n        code: u32,\n        data: &[u8],\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let channel = ServerChannelId(channel);\n        let data = Bytes::from(data.to_vec());\n        let (tx, rx) = oneshot::channel();\n\n        self.send_event(ServerHandlerEvent::ExtendedData(channel, data, code, tx))?;\n        let _ = rx.await;\n        Ok(())\n    }\n\n    async fn channel_close(\n        &mut self,\n        channel: ChannelId,\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let channel = ServerChannelId(channel);\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::ChannelClose(channel, tx))?;\n        let _ = rx.await;\n        Ok(())\n    }\n\n    async fn window_change_request(\n        &mut self,\n        channel: ChannelId,\n        col_width: u32,\n        row_height: u32,\n        pix_width: u32,\n        pix_height: u32,\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::WindowChangeRequest(\n            ServerChannelId(channel),\n            PtyRequest {\n                term: \"\".to_string(),\n                col_width,\n                row_height,\n                pix_width,\n                pix_height,\n                modes: vec![],\n            },\n            tx,\n        ))?;\n        let _ = rx.await;\n        Ok(())\n    }\n\n    async fn channel_eof(\n        &mut self,\n        channel: ChannelId,\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let channel = ServerChannelId(channel);\n        let (tx, rx) = oneshot::channel();\n\n        self.event_tx\n            .send(ServerHandlerEvent::ChannelEof(channel, tx))\n            .map_err(|_| ServerHandlerError::ChannelSend)?;\n\n        let _ = rx.await;\n        Ok(())\n    }\n\n    async fn signal(\n        &mut self,\n        channel: ChannelId,\n        signal_name: russh::Sig,\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::Signal(\n            ServerChannelId(channel),\n            signal_name,\n            tx,\n        ))?;\n        let _ = rx.await;\n        Ok(())\n    }\n\n    async fn exec_request(\n        &mut self,\n        channel: ChannelId,\n        data: &[u8],\n        session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let data = Bytes::from(data.to_vec());\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::ExecRequest(\n            ServerChannelId(channel),\n            data,\n            tx,\n        ))?;\n\n        if rx.await.unwrap_or(false) {\n            session.channel_success(channel)?\n        } else {\n            session.channel_failure(channel)?\n        }\n\n        Ok(())\n    }\n\n    async fn env_request(\n        &mut self,\n        channel: ChannelId,\n        variable_name: &str,\n        variable_value: &str,\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let variable_name = variable_name.to_string();\n        let variable_value = variable_value.to_string();\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::EnvRequest(\n            ServerChannelId(channel),\n            variable_name,\n            variable_value,\n            tx,\n        ))?;\n        let _ = rx.await;\n        Ok(())\n    }\n\n    async fn channel_open_direct_tcpip(\n        &mut self,\n        channel: Channel<Msg>,\n        host_to_connect: &str,\n        port_to_connect: u32,\n        originator_address: &str,\n        originator_port: u32,\n        _session: &mut Session,\n    ) -> Result<bool, Self::Error> {\n        let host_to_connect = host_to_connect.to_string();\n        let originator_address = originator_address.to_string();\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::ChannelOpenDirectTcpIp(\n            ServerChannelId(channel.id()),\n            DirectTCPIPParams {\n                host_to_connect,\n                port_to_connect,\n                originator_address,\n                originator_port,\n            },\n            tx,\n        ))?;\n        let allowed = rx.await.unwrap_or(false);\n        Ok(allowed)\n    }\n\n    async fn channel_open_direct_streamlocal(\n        &mut self,\n        channel: Channel<Msg>,\n        socket_path: &str,\n        _session: &mut Session,\n    ) -> Result<bool, Self::Error> {\n        let socket_path = socket_path.to_string();\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::ChannelOpenDirectStreamlocal(\n            ServerChannelId(channel.id()),\n            socket_path,\n            tx,\n        ))?;\n        let allowed = rx.await.unwrap_or(false);\n        Ok(allowed)\n    }\n\n    async fn x11_request(\n        &mut self,\n        channel: ChannelId,\n        single_conection: bool,\n        x11_auth_protocol: &str,\n        x11_auth_cookie: &str,\n        x11_screen_number: u32,\n        _session: &mut Session,\n    ) -> Result<(), Self::Error> {\n        let x11_auth_protocol = x11_auth_protocol.to_string();\n        let x11_auth_cookie = x11_auth_cookie.to_string();\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::X11Request(\n            ServerChannelId(channel),\n            X11Request {\n                single_conection,\n                x11_auth_protocol,\n                x11_auth_cookie,\n                x11_screen_number,\n            },\n            tx,\n        ))?;\n        let _ = rx.await;\n        Ok(())\n    }\n\n    async fn tcpip_forward(\n        &mut self,\n        address: &str,\n        port: &mut u32,\n        session: &mut Session,\n    ) -> Result<bool, Self::Error> {\n        let address = address.to_string();\n        let port = *port;\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::TcpIpForward(address, port, tx))?;\n        let allowed = rx.await.unwrap_or(false);\n        if allowed {\n            session.request_success()\n        } else {\n            session.request_failure()\n        }\n        Ok(allowed)\n    }\n\n    async fn cancel_tcpip_forward(\n        &mut self,\n        address: &str,\n        port: u32,\n        session: &mut Session,\n    ) -> Result<bool, Self::Error> {\n        let address = address.to_string();\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::CancelTcpIpForward(address, port, tx))?;\n        let allowed = rx.await.unwrap_or(false);\n        if allowed {\n            session.request_success()\n        } else {\n            session.request_failure()\n        }\n        Ok(allowed)\n    }\n\n    async fn streamlocal_forward(\n        &mut self,\n        socket_path: &str,\n        session: &mut Session,\n    ) -> Result<bool, Self::Error> {\n        let socket_path = socket_path.to_string();\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::StreamlocalForward(socket_path, tx))?;\n        let allowed = rx.await.unwrap_or(false);\n        if allowed {\n            session.request_success()\n        } else {\n            session.request_failure()\n        }\n        Ok(allowed)\n    }\n\n    async fn cancel_streamlocal_forward(\n        &mut self,\n        socket_path: &str,\n        session: &mut Session,\n    ) -> Result<bool, Self::Error> {\n        let socket_path = socket_path.to_string();\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::CancelStreamlocalForward(\n            socket_path,\n            tx,\n        ))?;\n        let allowed = rx.await.unwrap_or(false);\n        if allowed {\n            session.request_success()\n        } else {\n            session.request_failure()\n        }\n        Ok(allowed)\n    }\n\n    async fn agent_request(\n        &mut self,\n        channel: ChannelId,\n        session: &mut Session,\n    ) -> Result<bool, Self::Error> {\n        let (tx, rx) = oneshot::channel();\n        self.send_event(ServerHandlerEvent::AgentForward(\n            ServerChannelId(channel),\n            tx,\n        ))?;\n        let allowed = rx.await.unwrap_or(false);\n        if allowed {\n            session.request_success()\n        } else {\n            session.request_failure()\n        }\n        Ok(allowed)\n    }\n}\n\nimpl Drop for ServerHandler {\n    fn drop(&mut self) {\n        debug!(\"Dropped\");\n        let _ = self.event_tx.send(ServerHandlerEvent::Disconnect);\n    }\n}\n\nimpl Debug for ServerHandler {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"ServerHandler\")\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/server/service_output.rs",
    "content": "use std::sync::atomic::AtomicBool;\nuse std::sync::Arc;\n\nuse ansi_term::Colour;\nuse bytes::Bytes;\nuse tokio::sync::{broadcast, mpsc};\n\npub const ERASE_PROGRESS_SPINNER: &str = \"\\r                        \\r\";\npub const ERASE_PROGRESS_SPINNER_BUF: &[u8] = ERASE_PROGRESS_SPINNER.as_bytes();\npub const LINEBREAK: &[u8] = \"\\n\".as_bytes();\n\n#[derive(Clone)]\npub struct ServiceOutput {\n    progress_visible: Arc<AtomicBool>,\n    abort_tx: mpsc::Sender<()>,\n    output_tx: broadcast::Sender<Bytes>,\n}\n\nimpl ServiceOutput {\n    pub fn new() -> Self {\n        let progress_visible = Arc::new(AtomicBool::new(false));\n        let (abort_tx, mut abort_rx) = mpsc::channel(1);\n        let output_tx = broadcast::channel(32).0;\n\n        tokio::spawn({\n            let output_tx = output_tx.clone();\n            let progress_visible = progress_visible.clone();\n            let ticks = \"⠁⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠠⠠⠤⠦⠖⠒⠐⠐⠒⠓⠋⠉⠈⠈\".chars().collect::<Vec<_>>();\n            let mut tick_index = 0;\n            async move {\n                loop {\n                    tokio::select! {\n                        _ = abort_rx.recv() => {\n                            return;\n                        }\n                        _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {\n                            if progress_visible.load(std::sync::atomic::Ordering::Relaxed) {\n                                tick_index = (tick_index + 1) % ticks.len();\n                                #[allow(clippy::indexing_slicing)]\n                                let tick = ticks[tick_index];\n                                let badge = Colour::Black.on(Colour::Blue).paint(format!(\" {tick} Warpgate connecting \")).to_string();\n                                let _ = output_tx.send(Bytes::from([ERASE_PROGRESS_SPINNER_BUF, badge.as_bytes()].concat()));\n                            }\n                        }\n                    }\n                }\n            }\n        });\n\n        ServiceOutput {\n            progress_visible,\n            abort_tx,\n            output_tx,\n        }\n    }\n\n    pub fn show_progress(&mut self) {\n        self.progress_visible\n            .store(true, std::sync::atomic::Ordering::Relaxed);\n    }\n\n    pub async fn hide_progress(&mut self) {\n        self.progress_visible\n            .store(false, std::sync::atomic::Ordering::Relaxed);\n        self.emit_output(Bytes::from_static(ERASE_PROGRESS_SPINNER_BUF));\n        self.emit_output(Bytes::from_static(LINEBREAK));\n    }\n\n    pub fn subscribe(&self) -> broadcast::Receiver<Bytes> {\n        self.output_tx.subscribe()\n    }\n\n    pub fn emit_output(&mut self, output: Bytes) {\n        let _ = self.output_tx.send(output);\n    }\n}\n\nimpl Drop for ServiceOutput {\n    fn drop(&mut self) {\n        let signal = std::mem::replace(&mut self.abort_tx, mpsc::channel(1).0);\n        tokio::spawn(async move { signal.send(()).await });\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/server/session.rs",
    "content": "use std::borrow::Cow;\nuse std::collections::hash_map::Entry::Vacant;\nuse std::collections::{HashMap, HashSet};\nuse std::net::{Ipv4Addr, SocketAddr};\nuse std::pin::Pin;\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse std::task::Poll;\n\nuse ansi_term::Colour;\nuse anyhow::{Context, Result};\nuse bimap::BiMap;\nuse bytes::Bytes;\nuse futures::{Future, FutureExt};\nuse russh::keys::{PublicKey, PublicKeyBase64};\nuse russh::{MethodKind, MethodSet, Sig};\nuse tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};\nuse tokio::sync::{broadcast, oneshot, Mutex};\nuse tracing::*;\nuse uuid::Uuid;\nuse warpgate_common::auth::{\n    AuthCredential, AuthResult, AuthSelector, AuthState, AuthStateUserInfo, CredentialKind,\n};\nuse warpgate_common::eventhub::{EventHub, EventSender, EventSubscription};\nuse warpgate_common::{\n    Secret, SessionId, SshHostKeyVerificationMode, Target, TargetOptions, TargetSSHOptions,\n    WarpgateError,\n};\nuse warpgate_core::recordings::{\n    self, ConnectionRecorder, TerminalRecorder, TerminalRecordingStreamId, TrafficConnectionParams,\n    TrafficRecorder,\n};\nuse warpgate_core::{\n    authorize_ticket, consume_ticket, ConfigProvider, Services, WarpgateServerHandle,\n};\n\nuse super::channel_writer::ChannelWriter;\nuse super::russh_handler::ServerHandlerEvent;\nuse super::service_output::ServiceOutput;\nuse super::session_handle::SessionHandleCommand;\nuse crate::compat::ContextExt;\nuse crate::server::get_allowed_auth_methods;\nuse crate::server::service_output::ERASE_PROGRESS_SPINNER;\nuse crate::{\n    ChannelOperation, ConnectionError, DirectTCPIPParams, PtyRequest, RCCommand, RCCommandReply,\n    RCEvent, RCState, RemoteClient, ServerChannelId, SshClientError, SshRecordingMetadata,\n    X11Request,\n};\n\n#[derive(Clone)]\n#[allow(clippy::large_enum_variant)]\nenum TargetSelection {\n    None,\n    NotFound(String),\n    Found(Target, TargetSSHOptions),\n}\n\n#[derive(Debug)]\nenum Event {\n    Command(SessionHandleCommand),\n    ServerHandler(ServerHandlerEvent),\n    ConsoleInput(Bytes),\n    ServiceOutput(Bytes),\n    Client(RCEvent),\n}\n\nenum KeyboardInteractiveState {\n    None,\n    OtpRequested,\n    WebAuthRequested(broadcast::Receiver<AuthResult>),\n}\n\nstruct CachedSuccessfulTicketAuth {\n    ticket: Secret<String>,\n    user_info: AuthStateUserInfo,\n}\n\n#[derive(Debug, Hash, PartialEq, Eq, Clone)]\npub enum TrafficRecorderKey {\n    Tcp(String, u32),\n    Socket(String),\n}\n\npub struct ServerSession {\n    pub id: SessionId,\n    username: Option<String>,\n    session_handle: Option<russh::server::Handle>,\n    pty_channels: Vec<Uuid>,\n    all_channels: Vec<Uuid>,\n    channel_recorders: HashMap<Uuid, TerminalRecorder>,\n    channel_map: BiMap<ServerChannelId, Uuid>,\n    channel_pty_size_map: HashMap<Uuid, PtyRequest>,\n    rc_tx: UnboundedSender<(RCCommand, Option<RCCommandReply>)>,\n    rc_abort_tx: UnboundedSender<()>,\n    rc_state: RCState,\n    remote_address: SocketAddr,\n    services: Services,\n    server_handle: Arc<Mutex<WarpgateServerHandle>>,\n    target: TargetSelection,\n    traffic_recorders: HashMap<TrafficRecorderKey, TrafficRecorder>,\n    traffic_connection_recorders: HashMap<Uuid, ConnectionRecorder>,\n    hub: EventHub<Event>,\n    event_sender: EventSender<Event>,\n    main_event_subscription: EventSubscription<Event>,\n    service_output: ServiceOutput,\n    channel_writer: ChannelWriter,\n    auth_state: Option<Arc<Mutex<AuthState>>>,\n    keyboard_interactive_state: KeyboardInteractiveState,\n    cached_successful_ticket_auth: Option<CachedSuccessfulTicketAuth>,\n    allowed_auth_methods: MethodSet,\n}\n\nfn session_debug_tag(id: &SessionId, remote_address: &SocketAddr) -> String {\n    format!(\"[{id} - {remote_address}]\")\n}\n\nimpl std::fmt::Debug for ServerSession {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", session_debug_tag(&self.id, &self.remote_address))\n    }\n}\n\nimpl ServerSession {\n    pub async fn start(\n        remote_address: SocketAddr,\n        services: &Services,\n        server_handle: Arc<Mutex<WarpgateServerHandle>>,\n        mut session_handle_rx: UnboundedReceiver<SessionHandleCommand>,\n        mut handler_event_rx: UnboundedReceiver<ServerHandlerEvent>,\n    ) -> Result<impl Future<Output = Result<()>>> {\n        let id = server_handle.lock().await.id();\n\n        let _span = info_span!(\"SSH\", session=%id);\n        let _enter = _span.enter();\n\n        let mut rc_handles = RemoteClient::create(id, services.clone())?;\n\n        let (hub, event_sender) = EventHub::setup();\n        let main_event_subscription = hub\n            .subscribe(|e| !matches!(e, Event::ConsoleInput(_)))\n            .await;\n\n        let mut this = Self {\n            id,\n            username: None,\n            session_handle: None,\n            pty_channels: vec![],\n            all_channels: vec![],\n            channel_recorders: HashMap::new(),\n            channel_map: BiMap::new(),\n            channel_pty_size_map: HashMap::new(),\n            rc_tx: rc_handles.command_tx.clone(),\n            rc_abort_tx: rc_handles.abort_tx,\n            rc_state: RCState::NotInitialized,\n            remote_address,\n            services: services.clone(),\n            server_handle,\n            target: TargetSelection::None,\n            traffic_recorders: HashMap::new(),\n            traffic_connection_recorders: HashMap::new(),\n            hub,\n            event_sender: event_sender.clone(),\n            main_event_subscription,\n            service_output: ServiceOutput::new(),\n            channel_writer: ChannelWriter::new(),\n            auth_state: None,\n            keyboard_interactive_state: KeyboardInteractiveState::None,\n            cached_successful_ticket_auth: None,\n            allowed_auth_methods: get_allowed_auth_methods(services).await?,\n        };\n\n        let mut so_rx = this.service_output.subscribe();\n        let so_sender = event_sender.clone();\n        tokio::spawn(async move {\n            loop {\n                match so_rx.recv().await {\n                    Ok(data) => {\n                        if so_sender\n                            .send_once(Event::ServiceOutput(data))\n                            .await\n                            .is_err()\n                        {\n                            break;\n                        }\n                    }\n                    Err(broadcast::error::RecvError::Closed) => break,\n                    Err(_) => (),\n                }\n            }\n        });\n\n        let name = format!(\"SSH {id} session control\");\n        tokio::task::Builder::new().name(&name).spawn({\n            let sender = event_sender.clone();\n            async move {\n                while let Some(command) = session_handle_rx.recv().await {\n                    if sender.send_once(Event::Command(command)).await.is_err() {\n                        break;\n                    }\n                }\n            }\n        })?;\n\n        let name = format!(\"SSH {id} client events\");\n        tokio::task::Builder::new().name(&name).spawn({\n            let sender = event_sender.clone();\n            async move {\n                while let Some(e) = rc_handles.event_rx.recv().await {\n                    if sender.send_once(Event::Client(e)).await.is_err() {\n                        break;\n                    }\n                }\n            }\n        })?;\n\n        let name = format!(\"SSH {id} server handler events\");\n        tokio::task::Builder::new().name(&name).spawn({\n            let sender: EventSender<Event> = event_sender.clone();\n            async move {\n                while let Some(e) = handler_event_rx.recv().await {\n                    if sender.send_once(Event::ServerHandler(e)).await.is_err() {\n                        break;\n                    }\n                }\n            }\n        })?;\n\n        Ok(async move {\n            while let Some(event) = this.get_next_event().await {\n                this.handle_event(event).await?;\n            }\n            debug!(\"No more events\");\n            Ok::<_, anyhow::Error>(())\n        })\n    }\n\n    async fn get_next_event(&mut self) -> Option<Event> {\n        self.main_event_subscription.recv().await\n    }\n\n    async fn get_auth_state(&mut self, username: &str) -> Result<Arc<Mutex<AuthState>>> {\n        #[allow(clippy::unwrap_used)]\n        if self.auth_state.is_none()\n            || self\n                .auth_state\n                .as_ref()\n                .unwrap()\n                .lock()\n                .await\n                .user_info()\n                .username\n                != username\n        {\n            let state = self\n                .services\n                .auth_state_store\n                .lock()\n                .await\n                .create(\n                    Some(&self.id),\n                    username,\n                    crate::PROTOCOL_NAME,\n                    &[\n                        CredentialKind::Password,\n                        CredentialKind::PublicKey,\n                        CredentialKind::Totp,\n                        CredentialKind::WebUserApproval,\n                    ],\n                )\n                .await?\n                .1;\n            self.auth_state = Some(state);\n        }\n        #[allow(clippy::unwrap_used)]\n        Ok(self.auth_state.as_ref().cloned().unwrap())\n    }\n\n    pub fn make_logging_span(&self) -> tracing::Span {\n        let client_ip = self.remote_address.ip().to_string();\n        match self.username {\n            Some(ref username) => {\n                info_span!(\"SSH\", session=%self.id, session_username=%username, %client_ip)\n            }\n            None => info_span!(\"SSH\", session=%self.id, %client_ip),\n        }\n    }\n\n    fn map_channel(&self, ch: &ServerChannelId) -> Result<Uuid, WarpgateError> {\n        self.channel_map\n            .get_by_left(ch)\n            .cloned()\n            .ok_or(WarpgateError::InconsistentState)\n    }\n\n    fn map_channel_reverse(&self, ch: &Uuid) -> Result<ServerChannelId> {\n        self.channel_map\n            .get_by_right(ch)\n            .cloned()\n            .ok_or_else(|| anyhow::anyhow!(\"Channel not known\"))\n    }\n\n    pub async fn emit_service_message(&mut self, msg: &str) -> Result<()> {\n        debug!(\"Service message: {}\", msg);\n\n        self.service_output.emit_output(Bytes::from(format!(\n            \"{}{} {}\\r\\n\",\n            ERASE_PROGRESS_SPINNER,\n            Colour::Black.on(Colour::White).paint(\" Warpgate \"),\n            msg.replace('\\n', \"\\r\\n\"),\n        )));\n\n        Ok(())\n    }\n\n    pub async fn emit_pty_output(&mut self, data: &[u8]) -> Result<()> {\n        let channels = self.pty_channels.clone();\n        for channel in channels {\n            let channel = self.map_channel_reverse(&channel)?;\n            if let Some(session) = self.session_handle.clone() {\n                self.channel_writer.write(session, channel.0, data);\n            }\n        }\n        Ok(())\n    }\n\n    /// Start connecting to the target if we aren't already.\n    ///\n    /// Timing of this call is important because if the client connection is\n    /// an interactive session *in principle* (e.g a normal interactive OpenSSH\n    /// session but maybe with some port forwards or agent)\n    /// Ideally, it needs to be called by the time we already have the interactive\n    /// channel open if we will ever have one to prevent bugs like\n    /// https://github.com/warp-tech/warpgate/issues/1286\n    /// where a PTY channel is required for the host key prompt, but we've connected\n    /// faster than the client could open one.\n    pub async fn maybe_connect_remote(&mut self) -> Result<()> {\n        match self.target.clone() {\n            TargetSelection::None => {\n                anyhow::bail!(\"Invalid session state (target not set)\")\n            }\n            TargetSelection::NotFound(name) => {\n                self.emit_service_message(&format!(\"Selected target not found: {name}\"))\n                    .await?;\n                self.disconnect_server().await;\n                anyhow::bail!(\"Target not found: {}\", name);\n            }\n            TargetSelection::Found(target, ssh_options) => {\n                if self.rc_state == RCState::NotInitialized {\n                    self.connect_remote(target, ssh_options).await?;\n                }\n            }\n        }\n        Ok(())\n    }\n\n    async fn connect_remote(\n        &mut self,\n        target: Target,\n        ssh_options: TargetSSHOptions,\n    ) -> Result<()> {\n        self.rc_state = RCState::Connecting;\n        self.send_command(RCCommand::Connect(ssh_options))\n            .map_err(|_| anyhow::anyhow!(\"cannot send command\"))?;\n        self.service_output.show_progress();\n        self.emit_service_message(&format!(\"Selected target: {}\", target.name))\n            .await?;\n\n        Ok(())\n    }\n\n    fn handle_event<'a>(\n        &'a mut self,\n        event: Event,\n    ) -> Pin<Box<dyn Future<Output = Result<(), WarpgateError>> + Send + 'a>> {\n        async move {\n            match event {\n                Event::Client(RCEvent::Done) => Err(WarpgateError::SessionEnd)?,\n                Event::ServerHandler(ServerHandlerEvent::Disconnect) => {\n                    Err(WarpgateError::SessionEnd)?\n                }\n                Event::Client(e) => {\n                    debug!(event=?e, \"Event\");\n                    let span = self.make_logging_span();\n                    if let Err(err) = self.handle_remote_event(e).instrument(span).await {\n                        error!(\"Client event handler error: {:?}\", err);\n                        // break;\n                    }\n                }\n                Event::ServerHandler(e) => {\n                    let span = self.make_logging_span();\n                    if let Err(err) = self.handle_server_handler_event(e).instrument(span).await {\n                        error!(\"Server event handler error: {:?}\", err);\n                        // break;\n                    }\n                }\n                Event::Command(command) => {\n                    debug!(?command, \"Session control\");\n                    if let Err(err) = self.handle_session_control(command).await {\n                        error!(\"Command handler error: {:?}\", err);\n                        // break;\n                    }\n                }\n                Event::ServiceOutput(data) => {\n                    let _ = self.emit_pty_output(&data).await;\n                }\n                Event::ConsoleInput(_) => (),\n            }\n            Ok(())\n        }\n        .boxed()\n    }\n\n    async fn handle_server_handler_event(&mut self, event: ServerHandlerEvent) -> Result<()> {\n        match event {\n            ServerHandlerEvent::Authenticated(handle) => {\n                self.session_handle = Some(handle.0);\n            }\n\n            ServerHandlerEvent::ChannelOpenSession(server_channel_id, reply) => {\n                let channel = Uuid::new_v4();\n                self.channel_map.insert(server_channel_id, channel);\n\n                info!(%channel, \"Opening session channel\");\n                return match self\n                    .send_command_and_wait(RCCommand::Channel(channel, ChannelOperation::OpenShell))\n                    .await\n                {\n                    Ok(()) => {\n                        self.all_channels.push(channel);\n                        let _ = reply.send(true);\n                        Ok(())\n                    }\n                    Err(SshClientError::Russh(russh::Error::ChannelOpenFailure(_))) => {\n                        let _ = reply.send(false);\n                        Ok(())\n                    }\n                    Err(x) => Err(x.into()),\n                };\n            }\n\n            ServerHandlerEvent::SubsystemRequest(server_channel_id, name, reply) => {\n                return match self\n                    ._channel_subsystem_request(server_channel_id, name)\n                    .await\n                {\n                    Ok(()) => {\n                        let _ = reply.send(true);\n                        Ok(())\n                    }\n                    Err(SshClientError::Russh(russh::Error::ChannelOpenFailure(_))) => {\n                        let _ = reply.send(false);\n                        Ok(())\n                    }\n                    Err(x) => Err(x.into()),\n                }\n            }\n\n            ServerHandlerEvent::PtyRequest(server_channel_id, request, reply) => {\n                let channel_id = self.map_channel(&server_channel_id)?;\n                self.channel_pty_size_map\n                    .insert(channel_id, request.clone());\n                if let Some(recorder) = self.channel_recorders.get_mut(&channel_id) {\n                    if let Err(error) = recorder\n                        .write_pty_resize(request.col_width, request.row_height)\n                        .await\n                    {\n                        error!(%channel_id, ?error, \"Failed to record terminal data\");\n                        self.channel_recorders.remove(&channel_id);\n                    }\n                }\n                self.send_command_and_wait(RCCommand::Channel(\n                    channel_id,\n                    ChannelOperation::RequestPty(request),\n                ))\n                .await?;\n                let _ = self\n                    .session_handle\n                    .as_mut()\n                    .context(\"Invalid session state\")?\n                    .channel_success(server_channel_id.0)\n                    .await;\n                self.pty_channels.push(channel_id);\n                let _ = reply.send(());\n            }\n\n            ServerHandlerEvent::ShellRequest(server_channel_id, reply) => {\n                let channel_id = self.map_channel(&server_channel_id)?;\n                let _ = self.maybe_connect_remote().await;\n\n                let _ = self.send_command(RCCommand::Channel(\n                    channel_id,\n                    ChannelOperation::RequestShell,\n                ));\n\n                self.start_terminal_recording(\n                    channel_id,\n                    SshRecordingMetadata::Shell {\n                        // HACK russh ChannelId is opaque except via Display\n                        channel: server_channel_id.0.to_string().parse().unwrap_or_default(),\n                    },\n                )\n                .await;\n\n                info!(%channel_id, \"Opening shell\");\n\n                let _ = self\n                    .session_handle\n                    .as_mut()\n                    .context(\"Invalid session state\")?\n                    .channel_success(server_channel_id.0)\n                    .await;\n\n                let _ = reply.send(true);\n            }\n\n            ServerHandlerEvent::AuthPublicKey(username, key, reply) => {\n                let _ = reply.send(self._auth_publickey(username, key).await);\n            }\n\n            ServerHandlerEvent::AuthPublicKeyOffer(username, key, reply) => {\n                let _ = reply.send(self._auth_publickey_offer(username, key).await);\n            }\n\n            ServerHandlerEvent::AuthPassword(username, password, reply) => {\n                let _ = reply.send(self._auth_password(username, password).await);\n            }\n\n            ServerHandlerEvent::AuthKeyboardInteractive(username, response, reply) => {\n                let _ = reply.send(self._auth_keyboard_interactive(username, response).await);\n            }\n\n            ServerHandlerEvent::Data(channel, data, reply) => {\n                self._data(channel, data).await?;\n                let _ = reply.send(());\n            }\n\n            ServerHandlerEvent::ExtendedData(channel, data, code, reply) => {\n                self._extended_data(channel, code, data).await?;\n                let _ = reply.send(());\n            }\n\n            ServerHandlerEvent::ChannelClose(channel, reply) => {\n                self._channel_close(channel).await?;\n                let _ = reply.send(());\n            }\n\n            ServerHandlerEvent::ChannelEof(channel, reply) => {\n                self._channel_eof(channel).await?;\n                let _ = reply.send(());\n            }\n\n            ServerHandlerEvent::WindowChangeRequest(channel, request, reply) => {\n                self._window_change_request(channel, request).await?;\n                let _ = reply.send(());\n            }\n\n            ServerHandlerEvent::Signal(channel, signal, reply) => {\n                self._channel_signal(channel, signal).await?;\n                let _ = reply.send(());\n            }\n\n            ServerHandlerEvent::ExecRequest(channel, data, reply) => {\n                self._channel_exec_request(channel, data).await?;\n                let _ = reply.send(true);\n            }\n\n            ServerHandlerEvent::ChannelOpenDirectTcpIp(channel, params, reply) => {\n                let _ = reply.send(self._channel_open_direct_tcpip(channel, params).await?);\n            }\n\n            ServerHandlerEvent::ChannelOpenDirectStreamlocal(channel, path, reply) => {\n                let _ = reply.send(self._channel_open_direct_streamlocal(channel, path).await?);\n            }\n\n            ServerHandlerEvent::EnvRequest(channel, name, value, reply) => {\n                self._channel_env_request(channel, name, value).await?;\n                let _ = reply.send(());\n            }\n\n            ServerHandlerEvent::X11Request(channel, request, reply) => {\n                self._channel_x11_request(channel, request).await?;\n                let _ = reply.send(());\n            }\n\n            ServerHandlerEvent::TcpIpForward(address, port, reply) => {\n                self._tcpip_forward(address, port).await?;\n                let _ = reply.send(true);\n            }\n\n            ServerHandlerEvent::CancelTcpIpForward(address, port, reply) => {\n                self._cancel_tcpip_forward(address, port).await?;\n                let _ = reply.send(true);\n            }\n\n            ServerHandlerEvent::StreamlocalForward(socket_path, reply) => {\n                self._streamlocal_forward(socket_path).await?;\n                let _ = reply.send(true);\n            }\n\n            ServerHandlerEvent::CancelStreamlocalForward(socket_path, reply) => {\n                self._cancel_streamlocal_forward(socket_path).await?;\n                let _ = reply.send(true);\n            }\n\n            ServerHandlerEvent::AgentForward(channel, reply) => {\n                self._agent_forward(channel).await?;\n                let _ = reply.send(true);\n            }\n\n            ServerHandlerEvent::Disconnect => (),\n        }\n\n        Ok(())\n    }\n\n    pub async fn handle_session_control(&mut self, command: SessionHandleCommand) -> Result<()> {\n        match command {\n            SessionHandleCommand::Close => {\n                let _ = self.emit_service_message(\"Session closed by admin\").await;\n                info!(\"Session closed by admin\");\n                self.request_disconnect().await;\n                self.disconnect_server().await;\n            }\n        }\n        Ok(())\n    }\n\n    pub async fn handle_remote_event(&mut self, event: RCEvent) -> Result<()> {\n        match event {\n            RCEvent::State(state) => {\n                self.rc_state = state;\n                match &self.rc_state {\n                    RCState::Connected => {\n                        self.service_output.hide_progress().await;\n                        self.service_output.emit_output(Bytes::from(format!(\n                            \"{}{}\\r\\n\",\n                            ERASE_PROGRESS_SPINNER,\n                            Colour::Black\n                                .on(Colour::Green)\n                                .paint(\" ✓ Warpgate connected \")\n                        )));\n                    }\n                    RCState::Disconnected => {\n                        self.service_output.hide_progress().await;\n                        self.disconnect_server().await;\n                    }\n                    _ => {}\n                }\n            }\n            RCEvent::ConnectionError(error) => {\n                self.service_output.hide_progress().await;\n\n                match error {\n                    ConnectionError::HostKeyMismatch {\n                        received_key_type,\n                        received_key_base64,\n                        known_key_type,\n                        known_key_base64,\n                    } => {\n                        let msg = format!(\n                            concat!(\n                                \"Host key doesn't match the stored one.\\n\",\n                                \"Stored key   ({}): {}\\n\",\n                                \"Received key ({}): {}\",\n                            ),\n                            known_key_type,\n                            known_key_base64,\n                            received_key_type,\n                            received_key_base64\n                        );\n                        self.emit_service_message(&msg).await?;\n                        self.emit_service_message(\n                            \"If you know that the key is correct (e.g. it has been changed),\",\n                        )\n                        .await?;\n                        self.emit_service_message(\n                            \"you can remove the old key in the Warpgate management UI and try again\",\n                        )\n                        .await?;\n                    }\n                    ConnectionError::Authentication => {\n                        self.service_output.emit_output(Bytes::from(format!(\n                            \"{}{}\\r\\n\",\n                            ERASE_PROGRESS_SPINNER,\n                            Colour::Black\n                                .on(Colour::Red)\n                                .paint(\" ✗ SSH target rejected Warpgate authentication request \")\n                        )));\n                    }\n                    error => {\n                        self.service_output.emit_output(Bytes::from(format!(\n                            \"{}{} {}\\r\\n\",\n                            ERASE_PROGRESS_SPINNER,\n                            Colour::Black.on(Colour::Red).paint(\" ✗ Connection failed \"),\n                            error\n                        )));\n                    }\n                }\n            }\n            RCEvent::Error(e) => {\n                self.service_output.hide_progress().await;\n                let _ = self.emit_service_message(&format!(\"Error: {e}\")).await;\n                self.disconnect_server().await;\n            }\n            RCEvent::Output(channel, data) => {\n                if let Some(recorder) = self.channel_recorders.get_mut(&channel) {\n                    if let Err(error) = recorder\n                        .write(TerminalRecordingStreamId::Output, &data)\n                        .await\n                    {\n                        error!(%channel, ?error, \"Failed to record terminal data\");\n                        self.channel_recorders.remove(&channel);\n                    }\n                }\n\n                if let Some(recorder) = self.traffic_connection_recorders.get_mut(&channel) {\n                    if let Err(error) = recorder.write_rx(&data).await {\n                        error!(%channel, ?error, \"Failed to record traffic data\");\n                        self.traffic_connection_recorders.remove(&channel);\n                    }\n                }\n\n                let server_channel_id = self.map_channel_reverse(&channel)?;\n                if let Some(session) = self.session_handle.clone() {\n                    self.channel_writer\n                        .write(session, server_channel_id.0, data);\n                }\n            }\n            RCEvent::Success(channel) => {\n                let server_channel_id = self.map_channel_reverse(&channel)?;\n                self.maybe_with_session(|handle| async move {\n                    handle\n                        .channel_success(server_channel_id.0)\n                        .await\n                        .context(\"failed to send data\")\n                })\n                .await?;\n            }\n            RCEvent::ChannelFailure(channel) => {\n                let server_channel_id = self.map_channel_reverse(&channel)?;\n                self.maybe_with_session(|handle| async move {\n                    handle\n                        .channel_failure(server_channel_id.0)\n                        .await\n                        .context(\"failed to send data\")\n                })\n                .await?;\n            }\n            RCEvent::Close(channel) => {\n                // Flush any pending writes before closing the channel\n                let _ = self.channel_writer.flush().await;\n\n                let server_channel_id = self.map_channel_reverse(&channel)?;\n                let _ = self\n                    .maybe_with_session(|handle| async move {\n                        handle\n                            .close(server_channel_id.0)\n                            .await\n                            .context(\"failed to close ch\")\n                    })\n                    .await;\n            }\n            RCEvent::Eof(channel) => {\n                // Flush any pending writes before sending EOF\n                let _ = self.channel_writer.flush().await;\n\n                let server_channel_id = self.map_channel_reverse(&channel)?;\n                self.maybe_with_session(|handle| async move {\n                    handle\n                        .eof(server_channel_id.0)\n                        .await\n                        .context(\"failed to send eof\")\n                })\n                .await?;\n            }\n            RCEvent::ExitStatus(channel, code) => {\n                // Flush any pending writes before sending exit status\n                let _ = self.channel_writer.flush().await;\n\n                let server_channel_id = self.map_channel_reverse(&channel)?;\n                self.maybe_with_session(|handle| async move {\n                    handle\n                        .exit_status_request(server_channel_id.0, code)\n                        .await\n                        .context(\"failed to send exit status\")\n                })\n                .await?;\n            }\n            RCEvent::ExitSignal {\n                channel,\n                signal_name,\n                core_dumped,\n                error_message,\n                lang_tag,\n            } => {\n                let server_channel_id = self.map_channel_reverse(&channel)?;\n                self.maybe_with_session(|handle| async move {\n                    handle\n                        .exit_signal_request(\n                            server_channel_id.0,\n                            signal_name,\n                            core_dumped,\n                            error_message,\n                            lang_tag,\n                        )\n                        .await\n                        .context(\"failed to send exit status\")?;\n                    Ok(())\n                })\n                .await?;\n            }\n            RCEvent::Done => {}\n            RCEvent::ExtendedData { channel, data, ext } => {\n                if let Some(recorder) = self.channel_recorders.get_mut(&channel) {\n                    if let Err(error) = recorder\n                        .write(TerminalRecordingStreamId::Error, &data)\n                        .await\n                    {\n                        error!(%channel, ?error, \"Failed to record session data\");\n                        self.channel_recorders.remove(&channel);\n                    }\n                }\n                let server_channel_id = self.map_channel_reverse(&channel)?;\n                if let Some(session) = self.session_handle.clone() {\n                    self.channel_writer\n                        .write_extended(session, server_channel_id.0, ext, data);\n                }\n            }\n            RCEvent::HostKeyReceived(key) => {\n                self.emit_service_message(&format!(\n                    \"Host key ({}): {}\",\n                    key.algorithm(),\n                    key.public_key_base64()\n                ))\n                .await?;\n            }\n            RCEvent::HostKeyUnknown(key, reply) => {\n                self.handle_unknown_host_key(key, reply).await?;\n            }\n            RCEvent::ForwardedTcpIp(id, params) => {\n                if let Some(session) = &mut self.session_handle {\n                    let server_channel = session\n                        .channel_open_forwarded_tcpip(\n                            params.connected_address,\n                            params.connected_port,\n                            params.originator_address.clone(),\n                            params.originator_port,\n                        )\n                        .await?;\n\n                    self.channel_map\n                        .insert(ServerChannelId(server_channel.id()), id);\n                    self.all_channels.push(id);\n\n                    let recorder = self\n                        .traffic_recorder_for(\n                            TrafficRecorderKey::Tcp(\n                                params.originator_address.clone(),\n                                params.originator_port,\n                            ),\n                            SshRecordingMetadata::ForwardedTcpIp {\n                                host: params.originator_address,\n                                port: params.originator_port as u16,\n                            },\n                        )\n                        .await;\n                    if let Some(recorder) = recorder {\n                        #[allow(clippy::unwrap_used)]\n                        let mut recorder = recorder.connection(TrafficConnectionParams::Tcp {\n                            dst_addr: Ipv4Addr::from_str(\"2.2.2.2\").unwrap(),\n                            dst_port: params.connected_port as u16,\n                            src_addr: Ipv4Addr::from_str(\"1.1.1.1\").unwrap(),\n                            src_port: params.originator_port as u16,\n                        });\n                        if let Err(error) = recorder.write_connection_setup().await {\n                            error!(channel=%id, ?error, \"Failed to record connection setup\");\n                        }\n                        self.traffic_connection_recorders.insert(id, recorder);\n                    }\n                }\n            }\n            RCEvent::ForwardedStreamlocal(id, params) => {\n                if let Some(session) = &mut self.session_handle {\n                    let server_channel = session\n                        .channel_open_forwarded_streamlocal(params.socket_path.clone())\n                        .await?;\n\n                    self.channel_map\n                        .insert(ServerChannelId(server_channel.id()), id);\n                    self.all_channels.push(id);\n\n                    let recorder = self\n                        .traffic_recorder_for(\n                            TrafficRecorderKey::Socket(params.socket_path.clone()),\n                            SshRecordingMetadata::ForwardedSocket {\n                                path: params.socket_path.clone(),\n                            },\n                        )\n                        .await;\n                    if let Some(recorder) = recorder {\n                        #[allow(clippy::unwrap_used)]\n                        let mut recorder = recorder.connection(TrafficConnectionParams::Socket {\n                            socket_path: params.socket_path,\n                        });\n                        if let Err(error) = recorder.write_connection_setup().await {\n                            error!(channel=%id, ?error, \"Failed to record connection setup\");\n                        }\n                        self.traffic_connection_recorders.insert(id, recorder);\n                    }\n                }\n            }\n            RCEvent::ForwardedAgent(id) => {\n                if let Some(session) = &mut self.session_handle {\n                    let server_channel = session.channel_open_agent().await?;\n\n                    self.channel_map\n                        .insert(ServerChannelId(server_channel.id()), id);\n                    self.all_channels.push(id);\n                }\n            }\n            RCEvent::X11(id, originator_address, originator_port) => {\n                if let Some(session) = &mut self.session_handle {\n                    let server_channel = session\n                        .channel_open_x11(originator_address, originator_port)\n                        .await?;\n\n                    self.channel_map\n                        .insert(ServerChannelId(server_channel.id()), id);\n                    self.all_channels.push(id);\n                }\n            }\n        }\n        Ok(())\n    }\n\n    async fn handle_unknown_host_key(\n        &mut self,\n        key: PublicKey,\n        reply: oneshot::Sender<bool>,\n    ) -> Result<()> {\n        self.service_output.hide_progress().await;\n\n        let mode = self\n            .services\n            .config\n            .lock()\n            .await\n            .store\n            .ssh\n            .host_key_verification;\n\n        if mode == SshHostKeyVerificationMode::AutoAccept {\n            let _ = reply.send(true);\n            info!(\"Accepted untrusted host key (auto-accept is enabled)\");\n            return Ok(());\n        }\n\n        if mode == SshHostKeyVerificationMode::AutoReject {\n            let _ = reply.send(false);\n            info!(\"Rejected untrusted host key (auto-reject is enabled)\");\n            return Ok(());\n        }\n\n        if self.pty_channels.is_empty() {\n            warn!(\"Target host key is not trusted, but there is no active PTY channel to show the trust prompt on.\");\n            warn!(\n                \"Connect to this target with an interactive session once to accept the host key.\"\n            );\n            self.request_disconnect().await;\n            anyhow::bail!(\"No PTY channel to show an interactive prompt on\")\n        }\n\n        self.emit_service_message(&format!(\n            \"There is no trusted {} key for this host.\",\n            key.algorithm()\n        ))\n        .await?;\n        self.emit_service_message(\"Trust this key? (y/n)\").await?;\n\n        let mut sub = self\n            .hub\n            .subscribe(|e| matches!(e, Event::ConsoleInput(_)))\n            .await;\n\n        let mut service_output = self.service_output.clone();\n        tokio::spawn(async move {\n            loop {\n                match sub.recv().await {\n                    Some(Event::ConsoleInput(data)) => {\n                        if data == \"y\".as_bytes() {\n                            let _ = reply.send(true);\n                            break;\n                        } else if data == \"n\".as_bytes() {\n                            let _ = reply.send(false);\n                            break;\n                        }\n                    }\n                    None => break,\n                    _ => (),\n                }\n            }\n            service_output.show_progress();\n        });\n\n        Ok(())\n    }\n\n    async fn maybe_with_session<'a, FN, FT, R>(&'a mut self, f: FN) -> Result<Option<R>>\n    where\n        FN: FnOnce(&'a mut russh::server::Handle) -> FT + 'a,\n        FT: futures::Future<Output = Result<R>>,\n    {\n        if let Some(handle) = &mut self.session_handle {\n            return Ok(Some(f(handle).await?));\n        }\n        Ok(None)\n    }\n\n    async fn _channel_open_direct_tcpip(\n        &mut self,\n        channel: ServerChannelId,\n        params: DirectTCPIPParams,\n    ) -> Result<bool> {\n        let uuid = Uuid::new_v4();\n        self.channel_map.insert(channel, uuid);\n\n        info!(%channel, \"Opening direct TCP/IP channel from {}:{} to {}:{}\", params.originator_address, params.originator_port, params.host_to_connect, params.port_to_connect);\n\n        let _ = self.maybe_connect_remote().await;\n\n        match self\n            .send_command_and_wait(RCCommand::Channel(\n                uuid,\n                ChannelOperation::OpenDirectTCPIP(params.clone()),\n            ))\n            .await\n        {\n            Ok(()) => {\n                self.all_channels.push(uuid);\n\n                let recorder = self\n                    .traffic_recorder_for(\n                        TrafficRecorderKey::Tcp(\n                            params.host_to_connect.clone(),\n                            params.port_to_connect,\n                        ),\n                        SshRecordingMetadata::DirectTcpIp {\n                            host: params.host_to_connect,\n                            port: params.port_to_connect as u16,\n                        },\n                    )\n                    .await;\n                if let Some(recorder) = recorder {\n                    #[allow(clippy::unwrap_used)]\n                    let mut recorder = recorder.connection(TrafficConnectionParams::Tcp {\n                        dst_addr: Ipv4Addr::from_str(\"2.2.2.2\").unwrap(),\n                        dst_port: params.port_to_connect as u16,\n                        src_addr: Ipv4Addr::from_str(\"1.1.1.1\").unwrap(),\n                        src_port: params.originator_port as u16,\n                    });\n                    if let Err(error) = recorder.write_connection_setup().await {\n                        error!(%channel, ?error, \"Failed to record connection setup\");\n                    }\n                    self.traffic_connection_recorders.insert(uuid, recorder);\n                }\n\n                Ok(true)\n            }\n            Err(SshClientError::Russh(russh::Error::ChannelOpenFailure(_))) => Ok(false),\n            Err(x) => Err(x.into()),\n        }\n    }\n\n    async fn _channel_open_direct_streamlocal(\n        &mut self,\n        channel: ServerChannelId,\n        path: String,\n    ) -> Result<bool> {\n        let uuid = Uuid::new_v4();\n        self.channel_map.insert(channel, uuid);\n\n        info!(%channel, \"Opening direct streamlocal channel to {}\", path);\n\n        let _ = self.maybe_connect_remote().await;\n\n        match self\n            .send_command_and_wait(RCCommand::Channel(\n                uuid,\n                ChannelOperation::OpenDirectStreamlocal(path.clone()),\n            ))\n            .await\n        {\n            Ok(()) => {\n                self.all_channels.push(uuid);\n\n                let recorder = self\n                    .traffic_recorder_for(\n                        TrafficRecorderKey::Socket(path.clone()),\n                        SshRecordingMetadata::DirectSocket { path: path.clone() },\n                    )\n                    .await;\n                if let Some(recorder) = recorder {\n                    #[allow(clippy::unwrap_used)]\n                    let mut recorder =\n                        recorder.connection(TrafficConnectionParams::Socket { socket_path: path });\n                    if let Err(error) = recorder.write_connection_setup().await {\n                        error!(%channel, ?error, \"Failed to record connection setup\");\n                    }\n                    self.traffic_connection_recorders.insert(uuid, recorder);\n                }\n\n                Ok(true)\n            }\n            Err(SshClientError::Russh(russh::Error::ChannelOpenFailure(_))) => Ok(false),\n            Err(x) => Err(x.into()),\n        }\n    }\n\n    async fn _window_change_request(\n        &mut self,\n        server_channel_id: ServerChannelId,\n        request: PtyRequest,\n    ) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        self.channel_pty_size_map\n            .insert(channel_id, request.clone());\n        if let Some(recorder) = self.channel_recorders.get_mut(&channel_id) {\n            if let Err(error) = recorder\n                .write_pty_resize(request.col_width, request.row_height)\n                .await\n            {\n                error!(%channel_id, ?error, \"Failed to record terminal data\");\n                self.channel_recorders.remove(&channel_id);\n            }\n        }\n        self.send_command_and_wait(RCCommand::Channel(\n            channel_id,\n            ChannelOperation::ResizePty(request),\n        ))\n        .await?;\n        Ok(())\n    }\n\n    async fn _channel_exec_request(\n        &mut self,\n        server_channel_id: ServerChannelId,\n        data: Bytes,\n    ) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        match std::str::from_utf8(&data) {\n            Err(e) => {\n                error!(channel=%channel_id, ?data, \"Requested exec - invalid UTF-8\");\n                anyhow::bail!(e)\n            }\n            Ok::<&str, _>(command) => {\n                debug!(channel=%channel_id, %command, \"Requested exec\");\n                let _ = self.maybe_connect_remote().await;\n                let _ = self.send_command(RCCommand::Channel(\n                    channel_id,\n                    ChannelOperation::RequestExec(command.to_string()),\n                ));\n            }\n        }\n\n        self.start_terminal_recording(\n            channel_id,\n            SshRecordingMetadata::Exec {\n                // HACK russh ChannelId is opaque except via Display\n                channel: server_channel_id.0.to_string().parse().unwrap_or_default(),\n            },\n        )\n        .await;\n        Ok(())\n    }\n\n    async fn start_terminal_recording(&mut self, channel_id: Uuid, metadata: SshRecordingMetadata) {\n        let recorder = async {\n            let mut recorder = self\n                .services\n                .recordings\n                .lock()\n                .await\n                .start::<TerminalRecorder, _>(&self.id, None, metadata)\n                .await?;\n            if let Some(request) = self.channel_pty_size_map.get(&channel_id) {\n                recorder\n                    .write_pty_resize(request.col_width, request.row_height)\n                    .await?;\n            }\n            Ok::<_, recordings::Error>(recorder)\n        }\n        .await;\n        match recorder {\n            Ok(recorder) => {\n                self.channel_recorders.insert(channel_id, recorder);\n            }\n            Err(error) => match error {\n                recordings::Error::Disabled => (),\n                error => error!(channel=%channel_id, ?error, \"Failed to start recording\"),\n            },\n        }\n    }\n\n    async fn _channel_x11_request(\n        &mut self,\n        server_channel_id: ServerChannelId,\n        request: X11Request,\n    ) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        debug!(channel=%channel_id, \"Requested X11\");\n        let _ = self.maybe_connect_remote().await;\n        self.send_command_and_wait(RCCommand::Channel(\n            channel_id,\n            ChannelOperation::RequestX11(request),\n        ))\n        .await?;\n        Ok(())\n    }\n\n    async fn _channel_env_request(\n        &mut self,\n        server_channel_id: ServerChannelId,\n        name: String,\n        value: String,\n    ) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        debug!(channel=%channel_id, %name, %value, \"Environment\");\n        self.send_command_and_wait(RCCommand::Channel(\n            channel_id,\n            ChannelOperation::RequestEnv(name, value),\n        ))\n        .await?;\n        Ok(())\n    }\n\n    async fn traffic_recorder_for(\n        &mut self,\n        key: TrafficRecorderKey,\n        metadata: SshRecordingMetadata,\n    ) -> Option<&mut TrafficRecorder> {\n        if let Vacant(e) = self.traffic_recorders.entry(key.clone()) {\n            match self\n                .services\n                .recordings\n                .lock()\n                .await\n                .start(&self.id, None, metadata)\n                .await\n            {\n                Ok(recorder) => {\n                    e.insert(recorder);\n                }\n                Err(error) => {\n                    error!(?key, ?error, \"Failed to start recording\");\n                }\n            }\n        }\n        self.traffic_recorders.get_mut(&key)\n    }\n\n    pub async fn _channel_subsystem_request(\n        &mut self,\n        server_channel_id: ServerChannelId,\n        name: String,\n    ) -> Result<(), SshClientError> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        info!(channel=%channel_id, \"Requesting subsystem {}\", &name);\n        let _ = self.maybe_connect_remote().await;\n        self.send_command_and_wait(RCCommand::Channel(\n            channel_id,\n            ChannelOperation::RequestSubsystem(name),\n        ))\n        .await?;\n        Ok(())\n    }\n\n    async fn _data(&mut self, server_channel_id: ServerChannelId, data: Bytes) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        debug!(channel=%server_channel_id.0, ?data, \"Data\");\n        if self.rc_state == RCState::Connecting && data.first() == Some(&3) {\n            info!(channel=%channel_id, \"User requested connection abort (Ctrl-C)\");\n            self.request_disconnect().await;\n            return Ok(());\n        }\n\n        if let Some(recorder) = self.channel_recorders.get_mut(&channel_id) {\n            if let Err(error) = recorder\n                .write(TerminalRecordingStreamId::Input, &data)\n                .await\n            {\n                error!(channel=%channel_id, ?error, \"Failed to record terminal data\");\n                self.channel_recorders.remove(&channel_id);\n            }\n        }\n\n        if let Some(recorder) = self.traffic_connection_recorders.get_mut(&channel_id) {\n            if let Err(error) = recorder.write_tx(&data).await {\n                error!(channel=%channel_id, ?error, \"Failed to record traffic data\");\n                self.traffic_connection_recorders.remove(&channel_id);\n            }\n        }\n\n        if self.pty_channels.contains(&channel_id) {\n            let _ = self\n                .event_sender\n                .send_once(Event::ConsoleInput(data.clone()))\n                .await;\n        }\n\n        let _ = self.send_command(RCCommand::Channel(channel_id, ChannelOperation::Data(data)));\n        Ok(())\n    }\n\n    async fn _extended_data(\n        &mut self,\n        server_channel_id: ServerChannelId,\n        code: u32,\n        data: Bytes,\n    ) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        debug!(channel=%server_channel_id.0, ?data, \"Data\");\n        let _ = self.send_command(RCCommand::Channel(\n            channel_id,\n            ChannelOperation::ExtendedData { ext: code, data },\n        ));\n        Ok(())\n    }\n\n    async fn _tcpip_forward(&mut self, address: String, port: u32) -> Result<()> {\n        info!(%address, %port, \"Remote port forwarding requested\");\n        let _ = self.maybe_connect_remote().await;\n        self.send_command_and_wait(RCCommand::ForwardTCPIP(address, port))\n            .await\n            .map_err(anyhow::Error::from)\n    }\n\n    pub async fn _cancel_tcpip_forward(&mut self, address: String, port: u32) -> Result<()> {\n        info!(%address, %port, \"Remote port forwarding cancelled\");\n        self.send_command_and_wait(RCCommand::CancelTCPIPForward(address, port))\n            .await\n            .map_err(anyhow::Error::from)\n    }\n\n    async fn _streamlocal_forward(&mut self, socket_path: String) -> Result<()> {\n        info!(%socket_path, \"Remote UNIX socket forwarding requested\");\n        let _ = self.maybe_connect_remote().await;\n        self.send_command_and_wait(RCCommand::StreamlocalForward(socket_path))\n            .await\n            .map_err(anyhow::Error::from)\n    }\n\n    pub async fn _cancel_streamlocal_forward(&mut self, socket_path: String) -> Result<()> {\n        info!(%socket_path, \"Remote UNIX socket forwarding cancelled\");\n        self.send_command_and_wait(RCCommand::CancelStreamlocalForward(socket_path))\n            .await\n            .map_err(anyhow::Error::from)\n    }\n\n    async fn _agent_forward(&mut self, server_channel_id: ServerChannelId) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        debug!(channel=%channel_id, \"Requested Agent Forwarding\");\n        self.send_command_and_wait(RCCommand::Channel(\n            channel_id,\n            ChannelOperation::AgentForward,\n        ))\n        .await?;\n        Ok(())\n    }\n\n    async fn _auth_publickey_offer(\n        &mut self,\n        ssh_username: Secret<String>,\n        key: PublicKey,\n    ) -> russh::server::Auth {\n        let selector: AuthSelector = ssh_username.expose_secret().into();\n\n        info!(\n            \"Client offers public key auth as {selector:?} with key {}\",\n            key.public_key_base64()\n        );\n\n        if !self.allowed_auth_methods.contains(&MethodKind::PublicKey) {\n            warn!(\"Client attempted public key auth even though it was not advertised\");\n            return russh::server::Auth::reject();\n        }\n\n        if let Ok(true) = self\n            .try_validate_public_key_offer(\n                &selector,\n                Some(AuthCredential::PublicKey {\n                    kind: key.algorithm(),\n                    public_key_bytes: Bytes::from(key.public_key_bytes()),\n                }),\n            )\n            .await\n        {\n            return russh::server::Auth::Accept;\n        }\n\n        let selector: AuthSelector = ssh_username.expose_secret().into();\n        match self.try_auth_lazy(&selector, None).await {\n            Ok(AuthResult::Need(kinds)) => russh::server::Auth::Reject {\n                proceed_with_methods: Some(self.get_remaining_auth_methods(kinds)),\n                partial_success: false,\n            },\n            _ => russh::server::Auth::reject(),\n        }\n    }\n\n    async fn _auth_publickey(\n        &mut self,\n        ssh_username: Secret<String>,\n        key: PublicKey,\n    ) -> russh::server::Auth {\n        let selector: AuthSelector = ssh_username.expose_secret().into();\n\n        info!(\n            \"Public key auth as {selector:?} with key {}\",\n            key.public_key_base64()\n        );\n\n        if !self.allowed_auth_methods.contains(&MethodKind::PublicKey) {\n            warn!(\"Client attempted public key auth even though it was not advertised\");\n            return russh::server::Auth::reject();\n        }\n\n        let key = Some(AuthCredential::PublicKey {\n            kind: key.algorithm(),\n            public_key_bytes: Bytes::from(key.public_key_bytes()),\n        });\n\n        let result = self.try_auth_lazy(&selector, key.clone()).await;\n\n        match result {\n            Ok(AuthResult::Accepted { .. }) => {\n                // Update last_used timestamp\n                if let Err(err) = self\n                    .services\n                    .config_provider\n                    .lock()\n                    .await\n                    .update_public_key_last_used(key.clone())\n                    .await\n                {\n                    warn!(?err, \"Failed to update last_used for public key\");\n                }\n                russh::server::Auth::Accept\n            }\n            Ok(AuthResult::Rejected) => russh::server::Auth::Reject {\n                proceed_with_methods: Some(MethodSet::all()),\n                partial_success: false,\n            },\n            Ok(AuthResult::Need(kinds)) => russh::server::Auth::Reject {\n                proceed_with_methods: Some(self.get_remaining_auth_methods(kinds)),\n                partial_success: false,\n            },\n            Err(error) => {\n                error!(?error, \"Failed to verify credentials\");\n                russh::server::Auth::Reject {\n                    proceed_with_methods: None,\n                    partial_success: false,\n                }\n            }\n        }\n    }\n\n    async fn _auth_password(\n        &mut self,\n        ssh_username: Secret<String>,\n        password: Secret<String>,\n    ) -> russh::server::Auth {\n        let selector: AuthSelector = ssh_username.expose_secret().into();\n        info!(\"Password auth as {selector:?}\");\n\n        if !self.allowed_auth_methods.contains(&MethodKind::Password) {\n            warn!(\"Client attempted password auth even though it was not advertised\");\n            return russh::server::Auth::reject();\n        }\n\n        match self\n            .try_auth_lazy(&selector, Some(AuthCredential::Password(password)))\n            .await\n        {\n            Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept,\n            Ok(AuthResult::Rejected) => russh::server::Auth::reject(),\n            Ok(AuthResult::Need(kinds)) => russh::server::Auth::Reject {\n                proceed_with_methods: Some(self.get_remaining_auth_methods(kinds)),\n                partial_success: false,\n            },\n            Err(error) => {\n                error!(?error, \"Failed to verify credentials\");\n                russh::server::Auth::Reject {\n                    proceed_with_methods: None,\n                    partial_success: false,\n                }\n            }\n        }\n    }\n\n    async fn _auth_keyboard_interactive(\n        &mut self,\n        ssh_username: Secret<String>,\n        response: Option<Secret<String>>,\n    ) -> russh::server::Auth {\n        let selector: AuthSelector = ssh_username.expose_secret().into();\n        info!(\"Keyboard-interactive auth as {:?}\", selector);\n\n        if !self\n            .allowed_auth_methods\n            .contains(&MethodKind::KeyboardInteractive)\n        {\n            warn!(\"Client attempted keyboard-interactive auth even though it was not advertised\");\n            return russh::server::Auth::reject();\n        }\n\n        let cred;\n        match &mut self.keyboard_interactive_state {\n            KeyboardInteractiveState::None => {\n                cred = None;\n            }\n            KeyboardInteractiveState::OtpRequested => {\n                cred = response.map(AuthCredential::Otp);\n            }\n            KeyboardInteractiveState::WebAuthRequested(event) => {\n                cred = None;\n                let _ = event.recv().await;\n                // the auth state has been updated by now\n            }\n        }\n\n        self.keyboard_interactive_state = KeyboardInteractiveState::None;\n\n        match self.try_auth_lazy(&selector, cred).await {\n            Ok(AuthResult::Accepted { .. }) => russh::server::Auth::Accept,\n            Ok(AuthResult::Rejected) => russh::server::Auth::reject(),\n            Ok(AuthResult::Need(kinds)) => {\n                if kinds.contains(&CredentialKind::Totp) {\n                    self.keyboard_interactive_state = KeyboardInteractiveState::OtpRequested;\n                    russh::server::Auth::Partial {\n                        name: Cow::Borrowed(\"Two-factor authentication\"),\n                        instructions: Cow::Borrowed(\"\"),\n                        prompts: Cow::Owned(vec![(Cow::Borrowed(\"One-time password: \"), true)]),\n                    }\n                } else if kinds.contains(&CredentialKind::WebUserApproval) {\n                    let Some(auth_state) = self.auth_state.as_ref() else {\n                        return russh::server::Auth::Reject {\n                            proceed_with_methods: None,\n                            partial_success: false,\n                        };\n                    };\n                    let identification_string =\n                        auth_state.lock().await.identification_string().to_owned();\n                    let auth_state_id = *auth_state.lock().await.id();\n                    let event = self\n                        .services\n                        .auth_state_store\n                        .lock()\n                        .await\n                        .subscribe(auth_state_id);\n                    self.keyboard_interactive_state =\n                        KeyboardInteractiveState::WebAuthRequested(event);\n\n                    let login_url = match auth_state\n                        .lock()\n                        .await\n                        .construct_web_approval_url(&*self.services.config.lock().await)\n                    {\n                        Ok(login_url) => login_url,\n                        Err(error) => {\n                            error!(?error, \"Failed to construct external URL\");\n                            return russh::server::Auth::Reject {\n                                proceed_with_methods: None,\n                                partial_success: false,\n                            };\n                        }\n                    };\n\n                    russh::server::Auth::Partial {\n                        name: Cow::Borrowed(\"Warpgate authentication\"),\n                        instructions: Cow::Owned(format!(\n                            concat!(\n                            \"-----------------------------------------------------------------------\\n\",\n                            \"Warpgate authentication: please open the following URL in your browser:\\n\",\n                            \"{}\\n\\n\",\n                            \"Make sure you're seeing this security key: {}\\n\",\n                            \"-----------------------------------------------------------------------\\n\"\n                        ),\n                            login_url,\n                            identification_string\n                                .chars()\n                                .map(|x| x.to_string())\n                                .collect::<Vec<_>>()\n                                .join(\" \")\n                        )),\n                        prompts: Cow::Owned(vec![(Cow::Borrowed(\"Press Enter when done: \"), true)]),\n                    }\n                } else {\n                    russh::server::Auth::Reject {\n                        proceed_with_methods: None,\n                        partial_success: false,\n                    }\n                }\n            }\n            Err(error) => {\n                error!(?error, \"Failed to verify credentials\");\n                russh::server::Auth::Reject {\n                    proceed_with_methods: None,\n                    partial_success: false,\n                }\n            }\n        }\n    }\n\n    fn get_remaining_auth_methods(&self, kinds: HashSet<CredentialKind>) -> MethodSet {\n        let mut m = MethodSet::empty();\n\n        for cred_kind in kinds {\n            let method_kind = match cred_kind {\n                CredentialKind::Password => MethodKind::Password,\n                CredentialKind::Totp => MethodKind::KeyboardInteractive,\n                CredentialKind::WebUserApproval => MethodKind::KeyboardInteractive,\n                CredentialKind::PublicKey => MethodKind::PublicKey,\n                CredentialKind::Sso => MethodKind::KeyboardInteractive,\n                CredentialKind::Certificate => {\n                    // Certificate authentication is not supported for SSH protocol\n                    // This credential type is primarily for Kubernetes\n                    continue;\n                }\n            };\n            if self.allowed_auth_methods.contains(&method_kind) {\n                m.push(method_kind);\n            }\n        }\n\n        if m.contains(&MethodKind::KeyboardInteractive) {\n            // Ensure keyboard-interactive is always the last method\n            m.push(MethodKind::KeyboardInteractive);\n        }\n\n        m\n    }\n\n    async fn try_validate_public_key_offer(\n        &mut self,\n        selector: &AuthSelector,\n        credential: Option<AuthCredential>,\n    ) -> Result<bool> {\n        match selector {\n            AuthSelector::User { username, .. } => {\n                let cp = self.services.config_provider.clone();\n\n                if let Some(credential) = credential {\n                    return Ok(cp\n                        .lock()\n                        .await\n                        .validate_credential(username, &credential)\n                        .await?);\n                }\n\n                Ok(false)\n            }\n            _ => Ok(false),\n        }\n    }\n\n    /// As try_auth_lazy is called multiple times, this memoization prevents\n    /// consuming the ticket multiple times, depleting its uses.\n    async fn try_auth_lazy(\n        &mut self,\n        selector: &AuthSelector,\n        credential: Option<AuthCredential>,\n    ) -> Result<AuthResult> {\n        if let AuthSelector::Ticket { secret } = selector {\n            if let Some(ref csta) = self.cached_successful_ticket_auth {\n                // Only if the client hasn't maliciously changed the username\n                // between auth attempts\n                if &csta.ticket == secret {\n                    return Ok(AuthResult::Accepted {\n                        user_info: csta.user_info.clone(),\n                    });\n                }\n            }\n\n            let result = self.try_auth_eager(selector, credential).await?;\n            if let AuthResult::Accepted { ref user_info } = result {\n                self.cached_successful_ticket_auth = Some(CachedSuccessfulTicketAuth {\n                    ticket: secret.clone(),\n                    user_info: user_info.clone(),\n                });\n            }\n\n            return Ok(result);\n        }\n        self.try_auth_eager(selector, credential).await\n    }\n\n    async fn try_auth_eager(\n        &mut self,\n        selector: &AuthSelector,\n        credential: Option<AuthCredential>,\n    ) -> Result<AuthResult> {\n        match selector {\n            AuthSelector::User {\n                username,\n                target_name,\n            } => {\n                let cp = self.services.config_provider.clone();\n\n                let state_arc = self.get_auth_state(username).await?;\n                let mut state = state_arc.lock().await;\n\n                if let Some(credential) = credential {\n                    if cp\n                        .lock()\n                        .await\n                        .validate_credential(username, &credential)\n                        .await?\n                    {\n                        state.add_valid_credential(credential);\n                    }\n                }\n\n                let user_auth_result = state.verify();\n\n                match user_auth_result {\n                    AuthResult::Accepted { user_info } => {\n                        self.services\n                            .auth_state_store\n                            .lock()\n                            .await\n                            .complete(state.id())\n                            .await;\n                        let target_auth_result = {\n                            self.services\n                                .config_provider\n                                .lock()\n                                .await\n                                .authorize_target(&user_info.username, target_name)\n                                .await?\n                        };\n                        if !target_auth_result {\n                            warn!(\n                                \"Target {} not authorized for user {}\",\n                                target_name, username\n                            );\n                            return Ok(AuthResult::Rejected);\n                        }\n                        self._auth_accept(user_info.clone(), target_name).await?;\n                        Ok(AuthResult::Accepted { user_info })\n                    }\n                    x => Ok(x),\n                }\n            }\n            AuthSelector::Ticket { secret } => {\n                match authorize_ticket(&self.services.db, secret).await? {\n                    Some((ticket, user_info)) => {\n                        info!(\"Authorized for {} with a ticket\", ticket.target);\n                        consume_ticket(&self.services.db, &ticket.id).await?;\n                        self._auth_accept(user_info.clone(), &ticket.target).await?;\n\n                        Ok(AuthResult::Accepted { user_info })\n                    }\n                    None => Ok(AuthResult::Rejected),\n                }\n            }\n        }\n    }\n\n    async fn _auth_accept(\n        &mut self,\n        user_info: AuthStateUserInfo,\n        target_name: &str,\n    ) -> Result<(), WarpgateError> {\n        self.username = Some(user_info.username.clone());\n        let _ = self\n            .server_handle\n            .lock()\n            .await\n            .set_user_info(user_info.clone())\n            .await;\n\n        let target = {\n            self.services\n                .config_provider\n                .lock()\n                .await\n                .list_targets()\n                .await?\n                .iter()\n                .filter_map(|t| match t.options {\n                    TargetOptions::Ssh(ref options) => Some((t, options)),\n                    _ => None,\n                })\n                .find(|(t, _)| t.name == target_name)\n                .map(|(t, opt)| (t.clone(), opt.clone()))\n        };\n\n        let Some((target, mut ssh_options)) = target else {\n            self.target = TargetSelection::NotFound(target_name.to_string());\n            warn!(\"Selected target not found\");\n            return Ok(());\n        };\n\n        // Forward username from the authenticated user to the target, if target has no username\n        if ssh_options.username.is_empty() {\n            ssh_options.username = user_info.username.to_string();\n        }\n\n        let _ = self.server_handle.lock().await.set_target(&target).await;\n        self.target = TargetSelection::Found(target, ssh_options);\n        Ok(())\n    }\n\n    async fn _channel_close(&mut self, server_channel_id: ServerChannelId) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        debug!(channel=%channel_id, \"Closing channel\");\n        self.send_command_and_wait(RCCommand::Channel(channel_id, ChannelOperation::Close))\n            .await?;\n        Ok(())\n    }\n\n    async fn _channel_eof(&mut self, server_channel_id: ServerChannelId) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        debug!(channel=%channel_id, \"EOF\");\n        let _ = self.send_command(RCCommand::Channel(channel_id, ChannelOperation::Eof));\n        Ok(())\n    }\n\n    pub async fn _channel_signal(\n        &mut self,\n        server_channel_id: ServerChannelId,\n        signal: Sig,\n    ) -> Result<()> {\n        let channel_id = self.map_channel(&server_channel_id)?;\n        debug!(channel=%channel_id, ?signal, \"Signal\");\n        self.send_command_and_wait(RCCommand::Channel(\n            channel_id,\n            ChannelOperation::Signal(signal),\n        ))\n        .await?;\n        Ok(())\n    }\n\n    fn send_command(&mut self, command: RCCommand) -> Result<(), RCCommand> {\n        self.rc_tx.send((command, None)).map_err(|e| e.0 .0)\n    }\n\n    async fn send_command_and_wait(&mut self, command: RCCommand) -> Result<(), SshClientError> {\n        let (tx, rx) = oneshot::channel();\n        let mut cmd = match self.rc_tx.send((command, Some(tx))) {\n            Ok(_) => PendingCommand::Waiting(rx),\n            Err(_) => PendingCommand::Failed,\n        };\n\n        loop {\n            tokio::select! {\n                result = &mut cmd => {\n                    return result\n                }\n                event = self.get_next_event() => {\n                    match event {\n                        Some(event) => {\n                            self.handle_event(event).await.map_err(SshClientError::from)?\n                        }\n                        None => {Err(SshClientError::MpscError)?}\n                    };\n                }\n            }\n        }\n    }\n\n    pub async fn _disconnect(&mut self) {\n        debug!(\"Client disconnect requested\");\n        self.request_disconnect().await;\n    }\n\n    async fn request_disconnect(&mut self) {\n        debug!(\"Disconnecting\");\n        let _ = self.rc_abort_tx.send(());\n        if self.rc_state != RCState::NotInitialized && self.rc_state != RCState::Disconnected {\n            let _ = self.send_command(RCCommand::Disconnect);\n        }\n    }\n\n    async fn disconnect_server(&mut self) {\n        let all_channels = std::mem::take(&mut self.all_channels);\n        let channels = all_channels\n            .into_iter()\n            .map(|x| self.map_channel_reverse(&x))\n            .filter_map(|x| x.ok())\n            .collect::<Vec<_>>();\n\n        let _ = self\n            .maybe_with_session(|handle| async move {\n                for ch in channels {\n                    let _ = handle.close(ch.0).await;\n                }\n                Ok(())\n            })\n            .await;\n\n        self.session_handle = None;\n    }\n}\n\nimpl Drop for ServerSession {\n    fn drop(&mut self) {\n        let _ = self.rc_abort_tx.send(());\n        info!(\"Closed session\");\n        debug!(\"Dropped\");\n    }\n}\n\npub enum PendingCommand {\n    Waiting(oneshot::Receiver<Result<(), SshClientError>>),\n    Failed,\n}\n\nimpl Future for PendingCommand {\n    type Output = Result<(), SshClientError>;\n\n    fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {\n        match self.get_mut() {\n            PendingCommand::Waiting(ref mut rx) => match Pin::new(rx).poll(cx) {\n                Poll::Ready(result) => {\n                    Poll::Ready(result.unwrap_or(Err(SshClientError::MpscError)))\n                }\n                Poll::Pending => Poll::Pending,\n            },\n            PendingCommand::Failed => Poll::Ready(Err(SshClientError::MpscError)),\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-protocol-ssh/src/server/session_handle.rs",
    "content": "use tokio::sync::mpsc;\nuse warpgate_core::SessionHandle;\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub enum SessionHandleCommand {\n    Close,\n}\n\npub struct SSHSessionHandle {\n    sender: mpsc::UnboundedSender<SessionHandleCommand>,\n}\n\nimpl SSHSessionHandle {\n    pub fn new() -> (Self, mpsc::UnboundedReceiver<SessionHandleCommand>) {\n        let (sender, receiver) = mpsc::unbounded_channel();\n        (SSHSessionHandle { sender }, receiver)\n    }\n}\n\nimpl SessionHandle for SSHSessionHandle {\n    fn close(&mut self) {\n        let _ = self.sender.send(SessionHandleCommand::Close);\n    }\n}\n"
  },
  {
    "path": "warpgate-sso/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-sso\"\nversion = \"0.22.0\"\n\n[dependencies]\nbytes.workspace = true\nthiserror.workspace = true\ntokio.workspace = true\ntracing.workspace = true\nopenidconnect = { version = \"4.0\", default-features = false, features = [\n    \"reqwest\",\n    \"accept-string-booleans\",\n] }\nyup-oauth2 = \"12\"\nreqwest_12.workspace = true\nserde.workspace = true\nserde_json.workspace = true\nonce_cell = { version = \"1.17\", default-features = false }\njsonwebtoken = { version = \"9\", default-features = false, features = [\"use_pem\"] }\ndata-encoding.workspace = true\nfutures.workspace = true\nschemars.workspace = true\n"
  },
  {
    "path": "warpgate-sso/src/config.rs",
    "content": "use std::collections::HashMap;\nuse std::fmt::{Display, Formatter};\nuse std::time::SystemTime;\n\nuse data_encoding::BASE64;\nuse once_cell::sync::Lazy;\nuse openidconnect::{AuthType, ClientId, ClientSecret, IssuerUrl};\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\n\nuse crate::SsoError;\n\n/// A role mapping value that accepts either a single role or a list of roles.\n/// In YAML config: `\"group\": \"role\"` or `\"group\": [\"role1\", \"role2\"]`\n#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]\n#[serde(untagged)]\npub enum RoleMapping {\n    Single(String),\n    Multiple(Vec<String>),\n}\n\nimpl RoleMapping {\n    pub fn roles(&self) -> Vec<String> {\n        match self {\n            RoleMapping::Single(s) => vec![s.clone()],\n            RoleMapping::Multiple(v) => v.clone(),\n        }\n    }\n}\n\n#[allow(clippy::unwrap_used)]\npub static GOOGLE_ISSUER_URL: Lazy<IssuerUrl> =\n    Lazy::new(|| IssuerUrl::new(\"https://accounts.google.com\".to_string()).unwrap());\n\n#[allow(clippy::unwrap_used)]\npub static APPLE_ISSUER_URL: Lazy<IssuerUrl> =\n    Lazy::new(|| IssuerUrl::new(\"https://appleid.apple.com\".to_string()).unwrap());\n\n#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema)]\npub enum SsoProviderReturnUrlPrefix {\n    #[serde(rename = \"@\")]\n    #[default]\n    AtSign,\n    #[serde(rename = \"_\")]\n    Underscore,\n}\n\nimpl Display for SsoProviderReturnUrlPrefix {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        match self {\n            SsoProviderReturnUrlPrefix::AtSign => write!(f, \"@\"),\n            SsoProviderReturnUrlPrefix::Underscore => write!(f, \"_\"),\n        }\n    }\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]\npub struct SsoProviderConfig {\n    pub name: String,\n    pub label: Option<String>,\n    pub provider: SsoInternalProviderConfig,\n    pub return_domain_whitelist: Option<Vec<String>>,\n    #[serde(default)]\n    pub return_url_prefix: SsoProviderReturnUrlPrefix,\n    #[serde(default)]\n    pub auto_create_users: bool,\n    /// Default credential policy for auto-created users.\n    /// Keys: \"http\", \"ssh\", \"mysql\", \"postgres\"\n    /// Values: list of credential kinds e.g. [\"sso\"], [\"web\"], []\n    pub default_credential_policy: Option<serde_json::Value>,\n}\n\nimpl SsoProviderConfig {\n    pub fn label(&self) -> &str {\n        self.label\n            .as_deref()\n            .unwrap_or_else(|| self.provider.label())\n    }\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]\n#[serde(tag = \"type\")]\npub enum SsoInternalProviderConfig {\n    #[serde(rename = \"google\")]\n    Google {\n        #[schemars(with = \"String\")]\n        client_id: ClientId,\n        #[schemars(with = \"String\")]\n        client_secret: ClientSecret,\n        /// Service account email for Google Directory API group lookups\n        service_account_email: Option<String>,\n        /// PEM private key from the service account JSON key file\n        service_account_key: Option<String>,\n        /// A Google Workspace admin email for domain-wide delegation\n        admin_email: Option<String>,\n        /// Maps Google group email addresses to Warpgate role names.\n        /// Use \"*\" as a key to set a default role for any group not explicitly mapped.\n        role_mappings: Option<HashMap<String, RoleMapping>>,\n        admin_role_mappings: Option<HashMap<String, RoleMapping>>,\n    },\n    #[serde(rename = \"apple\")]\n    Apple {\n        #[schemars(with = \"String\")]\n        client_id: ClientId,\n        #[schemars(with = \"String\")]\n        client_secret: ClientSecret,\n        key_id: String,\n        team_id: String,\n    },\n    #[serde(rename = \"azure\")]\n    Azure {\n        #[schemars(with = \"String\")]\n        client_id: ClientId,\n        #[schemars(with = \"String\")]\n        client_secret: ClientSecret,\n        tenant: String,\n    },\n    #[serde(rename = \"custom\")]\n    Custom {\n        #[schemars(with = \"String\")]\n        client_id: ClientId,\n        #[schemars(with = \"String\")]\n        client_secret: ClientSecret,\n        #[schemars(with = \"String\")]\n        issuer_url: IssuerUrl,\n        scopes: Vec<String>,\n        role_mappings: Option<HashMap<String, RoleMapping>>,\n        admin_role_mappings: Option<HashMap<String, RoleMapping>>,\n        additional_trusted_audiences: Option<Vec<String>>,\n        #[serde(default)]\n        trust_unknown_audiences: bool,\n    },\n}\n\n#[derive(Debug, Serialize)]\nstruct AppleIDClaims<'a> {\n    sub: &'a str,\n    aud: &'a str,\n    exp: usize,\n    nbf: usize,\n    iss: &'a str,\n}\n\nimpl SsoInternalProviderConfig {\n    #[inline]\n    pub fn label(&self) -> &'static str {\n        match self {\n            SsoInternalProviderConfig::Google { .. } => \"Google\",\n            SsoInternalProviderConfig::Apple { .. } => \"Apple\",\n            SsoInternalProviderConfig::Azure { .. } => \"Azure\",\n            SsoInternalProviderConfig::Custom { .. } => \"SSO\",\n        }\n    }\n\n    #[inline]\n    pub fn client_id(&self) -> &ClientId {\n        match self {\n            SsoInternalProviderConfig::Google { client_id, .. }\n            | SsoInternalProviderConfig::Apple { client_id, .. }\n            | SsoInternalProviderConfig::Azure { client_id, .. }\n            | SsoInternalProviderConfig::Custom { client_id, .. } => client_id,\n        }\n    }\n\n    #[inline]\n    pub fn client_secret(&self) -> Result<ClientSecret, SsoError> {\n        Ok(match self {\n            SsoInternalProviderConfig::Google { client_secret, .. }\n            | SsoInternalProviderConfig::Azure { client_secret, .. }\n            | SsoInternalProviderConfig::Custom { client_secret, .. } => client_secret.clone(),\n            SsoInternalProviderConfig::Apple {\n                client_secret,\n                client_id,\n                key_id,\n                team_id,\n            } => {\n                let key_content =\n                    BASE64\n                        .decode(client_secret.secret().as_bytes())\n                        .map_err(|e| {\n                            SsoError::ConfigError(format!(\n                                \"could not decode base64 client_secret: {e}\"\n                            ))\n                        })?;\n                let key = jsonwebtoken::EncodingKey::from_ec_pem(&key_content).map_err(|e| {\n                    SsoError::ConfigError(format!(\n                        \"could not parse client_secret as a private key: {e}\"\n                    ))\n                })?;\n                let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);\n                header.kid = Some(key_id.into());\n\n                #[allow(clippy::unwrap_used)]\n                ClientSecret::new(jsonwebtoken::encode(\n                    &header,\n                    &AppleIDClaims {\n                        aud: &APPLE_ISSUER_URL,\n                        sub: client_id,\n                        exp: SystemTime::now()\n                            .duration_since(SystemTime::UNIX_EPOCH)\n                            .unwrap()\n                            .as_secs() as usize\n                            + 600,\n                        nbf: SystemTime::now()\n                            .duration_since(SystemTime::UNIX_EPOCH)\n                            .unwrap()\n                            .as_secs() as usize,\n                        iss: team_id,\n                    },\n                    &key,\n                )?)\n            }\n        })\n    }\n\n    #[inline]\n    pub fn issuer_url(&self) -> Result<IssuerUrl, SsoError> {\n        Ok(match self {\n            SsoInternalProviderConfig::Google { .. } => GOOGLE_ISSUER_URL.clone(),\n            SsoInternalProviderConfig::Apple { .. } => APPLE_ISSUER_URL.clone(),\n            SsoInternalProviderConfig::Azure { tenant, .. } => {\n                IssuerUrl::new(format!(\"https://login.microsoftonline.com/{tenant}/v2.0\"))?\n            }\n            SsoInternalProviderConfig::Custom { issuer_url, .. } => {\n                let mut url = issuer_url.url().clone();\n                let path = url.path().to_owned();\n                if let Some(path) = path.strip_suffix(\"/.well-known/openid-configuration\") {\n                    url.set_path(path);\n                    let url_string = url.to_string();\n                    IssuerUrl::new(url_string.trim_end_matches('/').into())?\n                } else {\n                    issuer_url.clone()\n                }\n            }\n        })\n    }\n\n    #[inline]\n    pub fn scopes(&self) -> Vec<String> {\n        match self {\n            SsoInternalProviderConfig::Google { .. } | SsoInternalProviderConfig::Azure { .. } => {\n                vec![\"email\".into(), \"profile\".into()]\n            }\n            SsoInternalProviderConfig::Custom { scopes, .. } => scopes.clone(),\n            SsoInternalProviderConfig::Apple { .. } => vec![],\n        }\n    }\n\n    #[inline]\n    pub fn extra_parameters(&self) -> HashMap<String, String> {\n        match self {\n            SsoInternalProviderConfig::Apple { .. } => {\n                let mut map = HashMap::new();\n                map.insert(\"response_mode\".to_string(), \"form_post\".to_string());\n                map\n            }\n            _ => HashMap::new(),\n        }\n    }\n\n    #[inline]\n    pub fn auth_type(&self) -> AuthType {\n        #[allow(clippy::match_like_matches_macro)]\n        match self {\n            SsoInternalProviderConfig::Apple { .. } => AuthType::RequestBody,\n            _ => AuthType::BasicAuth,\n        }\n    }\n\n    #[inline]\n    pub fn needs_pkce_verifier(&self) -> bool {\n        #[allow(clippy::match_like_matches_macro)]\n        match self {\n            SsoInternalProviderConfig::Apple { .. } => false,\n            _ => true,\n        }\n    }\n\n    #[inline]\n    pub fn role_mappings(&self) -> Option<HashMap<String, RoleMapping>> {\n        #[allow(clippy::match_like_matches_macro)]\n        match self {\n            SsoInternalProviderConfig::Google { role_mappings, .. }\n            | SsoInternalProviderConfig::Custom { role_mappings, .. } => role_mappings.clone(),\n            _ => None,\n        }\n    }\n\n    #[inline]\n    pub fn admin_role_mappings(&self) -> Option<HashMap<String, RoleMapping>> {\n        #[allow(clippy::match_like_matches_macro)]\n        match self {\n            SsoInternalProviderConfig::Google {\n                admin_role_mappings,\n                ..\n            }\n            | SsoInternalProviderConfig::Custom {\n                admin_role_mappings,\n                ..\n            } => admin_role_mappings.clone(),\n            _ => None,\n        }\n    }\n\n    #[inline]\n    pub fn additional_trusted_audiences(&self) -> Option<&Vec<String>> {\n        #[allow(clippy::match_like_matches_macro)]\n        match self {\n            SsoInternalProviderConfig::Custom {\n                additional_trusted_audiences,\n                ..\n            } => additional_trusted_audiences.as_ref(),\n            _ => None,\n        }\n    }\n\n    #[inline]\n    pub fn trust_unknown_audiences(&self) -> bool {\n        #[allow(clippy::match_like_matches_macro)]\n        match self {\n            SsoInternalProviderConfig::Custom {\n                trust_unknown_audiences,\n                ..\n            } => *trust_unknown_audiences,\n            _ => false,\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-sso/src/error.rs",
    "content": "use std::error::Error;\n\nuse openidconnect::{\n    reqwest, ClaimsVerificationError, ConfigurationError, SignatureVerificationError, SigningError,\n};\n\n#[derive(thiserror::Error, Debug)]\npub enum SsoError {\n    #[error(\"provider is OAuth2, not OIDC\")]\n    NotOidc,\n    #[error(\"the token was replaced in flight\")]\n    Mitm,\n    #[error(\"config parse error: {0}\")]\n    UrlParse(#[from] openidconnect::url::ParseError),\n    #[error(\"config error: {0}\")]\n    ConfigError(String),\n    #[error(\"provider discovery error: {0}\")]\n    Discovery(String),\n    #[error(\"code verification error: {0}\")]\n    Verification(String),\n    #[error(\"claims verification error: {0}\")]\n    ClaimsVerification(#[from] ClaimsVerificationError),\n    #[error(\"signing error: {0}\")]\n    Signing(#[from] SigningError),\n    #[error(\"reqwest: {0}\")]\n    Reqwest(#[from] reqwest::Error),\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(\"JWT error: {0}\")]\n    Jwt(#[from] jsonwebtoken::errors::Error),\n    #[error(\"signature verification: {0}\")]\n    SignatureVerification(#[from] SignatureVerificationError),\n    #[error(\"configuration: {0}\")]\n    Configuration(#[from] ConfigurationError),\n    #[error(\"Google Directory API error: {0}\")]\n    GoogleDirectory(String),\n    #[error(\"the OIDC provider doesn't support RP-initiated logout\")]\n    LogoutNotSupported,\n    #[error(transparent)]\n    Other(Box<dyn Error + Send + Sync>),\n}\n"
  },
  {
    "path": "warpgate-sso/src/google_groups.rs",
    "content": "use openidconnect::reqwest;\nuse serde::Deserialize;\nuse tracing::{debug, warn};\nuse yup_oauth2::{ServiceAccountAuthenticator, ServiceAccountKey};\n\nuse crate::config::SsoInternalProviderConfig;\nuse crate::SsoError;\n\n#[derive(Debug, Deserialize)]\nstruct DirectoryGroupsResponse {\n    #[serde(default)]\n    groups: Vec<DirectoryGroup>,\n    #[serde(rename = \"nextPageToken\")]\n    next_page_token: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct DirectoryGroup {\n    email: String,\n}\n\nconst DIRECTORY_SCOPE: &str = \"https://www.googleapis.com/auth/admin.directory.group.readonly\";\nconst GOOGLE_TOKEN_URL: &str = \"https://oauth2.googleapis.com/token\";\nconst DIRECTORY_GROUPS_URL: &str = \"https://admin.googleapis.com/admin/directory/v1/groups\";\n\n/// Fetches the user's Google Workspace group memberships via the Directory API.\n///\n/// Requires `service_account_email`, `service_account_key`, and `admin_email`\n/// to be configured on the Google SSO provider. These values should be\n/// resolved by the deployment environment before Warpgate reads the config.\n///\n/// Returns `Ok(None)` if not a Google provider or service account is not configured.\npub async fn fetch_groups_if_configured(\n    config: &SsoInternalProviderConfig,\n    user_email: Option<&str>,\n) -> Result<Option<Vec<String>>, SsoError> {\n    let SsoInternalProviderConfig::Google {\n        service_account_email: Some(ref sa_email),\n        service_account_key: Some(ref sa_key),\n        admin_email: Some(ref admin_email),\n        ..\n    } = config\n    else {\n        return Ok(None);\n    };\n\n    let Some(user_email) = user_email else {\n        warn!(\"Google group sync configured but user email not available from OIDC claims\");\n        return Ok(None);\n    };\n\n    debug!(\"Fetching Google groups for {user_email}\");\n\n    let http_client = reqwest::ClientBuilder::new().build()?;\n    let access_token = get_access_token(sa_email, sa_key, admin_email).await?;\n    let groups = fetch_user_groups(&http_client, &access_token, user_email).await?;\n\n    debug!(\"Google groups for {user_email}: {groups:?}\");\n    Ok(Some(groups))\n}\n\nasync fn parse_json_response<T: serde::de::DeserializeOwned>(\n    response: reqwest::Response,\n    context: &str,\n) -> Result<T, SsoError> {\n    let body = response\n        .text()\n        .await\n        .map_err(|e| SsoError::GoogleDirectory(format!(\"{context} read failed: {e}\")))?;\n    serde_json::from_str(&body)\n        .map_err(|e| SsoError::GoogleDirectory(format!(\"{context} parse failed: {e}\")))\n}\n\nasync fn get_access_token(\n    service_account_email: &str,\n    private_key_pem: &str,\n    admin_email: &str,\n) -> Result<String, SsoError> {\n    let key = ServiceAccountKey {\n        key_type: None,\n        project_id: None,\n        private_key_id: None,\n        private_key: private_key_pem.to_string(),\n        client_email: service_account_email.to_string(),\n        client_id: None,\n        auth_uri: None,\n        token_uri: GOOGLE_TOKEN_URL.to_string(),\n        auth_provider_x509_cert_url: None,\n        client_x509_cert_url: None,\n    };\n\n    let auth = ServiceAccountAuthenticator::builder(key)\n        .subject(admin_email.to_string())\n        .build()\n        .await\n        .map_err(|e| SsoError::GoogleDirectory(format!(\"authenticator init failed: {e}\")))?;\n\n    let token = auth\n        .token(&[DIRECTORY_SCOPE])\n        .await\n        .map_err(|e| SsoError::GoogleDirectory(format!(\"service account token request failed: {e}\"))).inspect_err(|_| {\n            warn!(\"Ensure that domain-wide delegation is enabled for your service account's client ID with a {DIRECTORY_SCOPE} scope and that Admin SDK API is enabled for your Google Cloud project: https://console.cloud.google.com/apis/library/admin.googleapis.com\");\n        })?;\n\n    Ok(token\n        .token()\n        .ok_or(SsoError::GoogleDirectory(\"no access token received\".into()))?\n        .to_string())\n}\n\nasync fn fetch_user_groups(\n    http_client: &reqwest::Client,\n    access_token: &str,\n    user_email: &str,\n) -> Result<Vec<String>, SsoError> {\n    let mut all_groups = Vec::new();\n    let mut page_token: Option<String> = None;\n\n    loop {\n        let mut req = http_client\n            .get(DIRECTORY_GROUPS_URL)\n            .bearer_auth(access_token)\n            .query(&[(\"userKey\", user_email)]);\n\n        if let Some(ref token) = page_token {\n            req = req.query(&[(\"pageToken\", token.as_str())]);\n        }\n\n        let response = req\n            .send()\n            .await\n            .map_err(|e| SsoError::GoogleDirectory(format!(\"group lookup failed: {e}\")))?;\n\n        if response.status() != 200 {\n            return Err(SsoError::GoogleDirectory(format!(\n                \"Google group lookup failed with status {}: {}\",\n                response.status(),\n                response.text().await.unwrap_or_default()\n            )));\n        }\n\n        let response = response\n            .error_for_status()\n            .map_err(|e| SsoError::GoogleDirectory(format!(\"group lookup failed: {e}\")))?;\n\n        let resp: DirectoryGroupsResponse = parse_json_response(response, \"group response\").await?;\n\n        all_groups.extend(resp.groups.into_iter().map(|g| g.email));\n\n        if let Some(token) = resp.next_page_token {\n            if !token.is_empty() {\n                page_token = Some(token);\n                continue;\n            }\n        }\n        break;\n    }\n\n    Ok(all_groups)\n}\n"
  },
  {
    "path": "warpgate-sso/src/lib.rs",
    "content": "mod config;\nmod error;\npub(crate) mod google_groups;\nmod request;\nmod response;\nmod sso;\n\npub use config::*;\npub use error::*;\npub use openidconnect::core::CoreIdToken;\npub use request::*;\npub use response::*;\npub use sso::*;\n"
  },
  {
    "path": "warpgate-sso/src/request.rs",
    "content": "use openidconnect::url::Url;\nuse openidconnect::{CsrfToken, Nonce, PkceCodeVerifier, RedirectUrl};\nuse serde::{Deserialize, Serialize};\nuse tracing::{debug, error};\n\nuse crate::{SsoClient, SsoError, SsoInternalProviderConfig, SsoLoginResponse};\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct SsoLoginRequest {\n    pub(crate) auth_url: Url,\n    pub(crate) csrf_token: CsrfToken,\n    pub(crate) nonce: Nonce,\n    pub(crate) redirect_url: RedirectUrl,\n    pub(crate) pkce_verifier: Option<PkceCodeVerifier>,\n    pub(crate) config: SsoInternalProviderConfig,\n}\n\nimpl SsoLoginRequest {\n    pub fn auth_url(&self) -> &Url {\n        &self.auth_url\n    }\n\n    pub fn csrf_token(&self) -> &CsrfToken {\n        &self.csrf_token\n    }\n\n    pub fn redirect_url(&self) -> &RedirectUrl {\n        &self.redirect_url\n    }\n\n    pub async fn verify_code(self, code: String) -> Result<SsoLoginResponse, SsoError> {\n        let config = self.config;\n        let result = SsoClient::new(config.clone())?\n            .finish_login(self.pkce_verifier, self.redirect_url, &self.nonce, code)\n            .await?;\n\n        debug!(\"OIDC claims: {:?}\", result.claims);\n        debug!(\"OIDC userinfo claims: {:?}\", result.userinfo_claims);\n\n        macro_rules! get_claim {\n            ($method:ident) => {\n                result\n                    .claims\n                    .$method()\n                    .or(result.userinfo_claims.as_ref().and_then(|x| x.$method()))\n            };\n        }\n\n        // If preferred_username is absent, fall back to `email`\n        let preferred_username = get_claim!(preferred_username)\n            .map(|x| x.as_str())\n            .map(ToString::to_string)\n            .or_else(|| {\n                get_claim!(email)\n                    .map(|x| x.as_str())\n                    .map(ToString::to_string)\n            });\n\n        let name = get_claim!(name)\n            .and_then(|x| x.get(None))\n            .map(|x| x.as_str())\n            .map(ToString::to_string);\n\n        let email = get_claim!(email)\n            .map(|x| x.as_str())\n            .map(ToString::to_string);\n\n        let email_verified = get_claim!(email_verified);\n\n        let info_claims = result.userinfo_claims.as_ref();\n\n        let (access_groups, admin_groups) =\n            match crate::google_groups::fetch_groups_if_configured(&config, email.as_deref()).await\n            {\n                Ok(Some(google_groups)) => (Some(google_groups.clone()), Some(google_groups)),\n                Ok(None) => (\n                    info_claims.and_then(|x| x.additional_claims().warpgate_roles.clone()),\n                    info_claims.and_then(|x| x.additional_claims().warpgate_admin_roles.clone()),\n                ),\n                Err(e) => {\n                    error!(\"Failed to fetch Google groups: {e}\");\n                    (None, None)\n                }\n            };\n\n        Ok(SsoLoginResponse {\n            preferred_username,\n            name,\n            email,\n            email_verified,\n            access_roles: access_groups,\n            admin_roles: admin_groups,\n            id_token: result.token.clone(),\n        })\n    }\n}\n"
  },
  {
    "path": "warpgate-sso/src/response.rs",
    "content": "use openidconnect::core::CoreIdToken;\n\n#[derive(Clone, Debug)]\npub struct SsoLoginResponse {\n    pub name: Option<String>,\n    pub email: Option<String>,\n    pub email_verified: Option<bool>,\n    pub access_roles: Option<Vec<String>>,\n    pub admin_roles: Option<Vec<String>>,\n    pub id_token: CoreIdToken,\n    pub preferred_username: Option<String>,\n}\n"
  },
  {
    "path": "warpgate-sso/src/sso.rs",
    "content": "use std::borrow::Cow;\nuse std::ops::Deref;\n\nuse futures::future::OptionFuture;\nuse openidconnect::core::{\n    CoreAuthenticationFlow, CoreClient, CoreGenderClaim, CoreIdToken, CoreIdTokenClaims,\n};\nuse openidconnect::url::Url;\nuse openidconnect::{\n    reqwest, AccessTokenHash, AdditionalClaims, AuthorizationCode, CsrfToken, DiscoveryError,\n    EndpointMaybeSet, EndpointNotSet, EndpointSet, LogoutRequest, Nonce, OAuth2TokenResponse,\n    PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, ProviderMetadataWithLogout,\n    RedirectUrl, RequestTokenError, Scope, TokenResponse, UserInfoClaims,\n};\nuse serde::{Deserialize, Serialize};\nuse tracing::error;\n\nuse crate::config::SsoInternalProviderConfig;\nuse crate::request::SsoLoginRequest;\nuse crate::SsoError;\n\n/// Deserialize a value that may be either a single string or a sequence of strings.\n///\n/// Some OIDC providers (e.g. oidc-mock) return a single claim value\n/// as a bare string rather than a one-element array.\n/// This deserializer accepts both forms.\nfn string_or_vec<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>\nwhere\n    D: serde::Deserializer<'de>,\n{\n    #[derive(Deserialize)]\n    #[serde(untagged)]\n    enum StringOrVec {\n        String(String),\n        Vec(Vec<String>),\n    }\n\n    Option::<StringOrVec>::deserialize(deserializer).map(|opt| {\n        opt.map(|sv| match sv {\n            StringOrVec::String(s) => vec![s],\n            StringOrVec::Vec(v) => v,\n        })\n    })\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct WarpgateClaims {\n    #[serde(default, deserialize_with = \"string_or_vec\")]\n    pub warpgate_roles: Option<Vec<String>>,\n    #[serde(default, deserialize_with = \"string_or_vec\")]\n    pub warpgate_admin_roles: Option<Vec<String>>,\n}\n\nimpl AdditionalClaims for WarpgateClaims {}\n\npub struct SsoResult {\n    pub token: CoreIdToken,\n    pub claims: CoreIdTokenClaims,\n    pub userinfo_claims: Option<UserInfoClaims<WarpgateClaims, CoreGenderClaim>>,\n}\n\npub struct SsoClient {\n    config: SsoInternalProviderConfig,\n    http_client: reqwest::Client,\n}\n\npub async fn discover_metadata(\n    config: &SsoInternalProviderConfig,\n    http_client: &reqwest::Client,\n) -> Result<ProviderMetadataWithLogout, SsoError> {\n    ProviderMetadataWithLogout::discover_async(config.issuer_url()?, http_client)\n        .await\n        .map_err(|e| {\n            SsoError::Discovery(match e {\n                DiscoveryError::Request(inner) => format!(\"Request error: {inner:?}\"),\n                e => format!(\"{e}\"),\n            })\n        })\n}\n\nasync fn make_client(\n    config: &SsoInternalProviderConfig,\n    http_client: &reqwest::Client,\n) -> Result<\n    CoreClient<\n        EndpointSet,      // HasAuthUrl\n        EndpointNotSet,   // HasDeviceAuthUrl\n        EndpointNotSet,   // HasIntrospectionUrl\n        EndpointNotSet,   // HasRevocationUrl\n        EndpointMaybeSet, // HasTokenUrl\n        EndpointMaybeSet, // HasUserInfoUrl\n    >,\n    SsoError,\n> {\n    let metadata = discover_metadata(config, http_client).await?;\n\n    let client = CoreClient::from_provider_metadata(\n        metadata,\n        config.client_id().clone(),\n        Some(config.client_secret()?),\n    )\n    .set_auth_type(config.auth_type());\n\n    Ok(client)\n}\n\nimpl SsoClient {\n    pub fn new(config: SsoInternalProviderConfig) -> Result<Self, SsoError> {\n        Ok(Self {\n            config,\n            http_client: reqwest::ClientBuilder::new().build()?,\n        })\n    }\n\n    pub async fn supports_single_logout(&self) -> Result<bool, SsoError> {\n        let metadata = discover_metadata(&self.config, &self.http_client).await?;\n        Ok(metadata\n            .additional_metadata()\n            .end_session_endpoint\n            .is_some())\n    }\n\n    pub async fn start_login(&self, redirect_url: String) -> Result<SsoLoginRequest, SsoError> {\n        let redirect_url = RedirectUrl::new(redirect_url)?;\n        let client = make_client(&self.config, &self.http_client).await?;\n        let mut auth_req = client\n            .authorize_url(\n                CoreAuthenticationFlow::AuthorizationCode,\n                CsrfToken::new_random,\n                Nonce::new_random,\n            )\n            .set_redirect_uri(Cow::Owned(redirect_url.clone()));\n\n        for (k, v) in self.config.extra_parameters() {\n            auth_req = auth_req.add_extra_param(k, v);\n        }\n\n        for scope in self.config.scopes() {\n            auth_req = auth_req.add_scope(Scope::new(scope.to_string()));\n        }\n\n        let pkce_verifier = if self.config.needs_pkce_verifier() {\n            let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();\n            auth_req = auth_req.set_pkce_challenge(pkce_challenge);\n            Some(pkce_verifier)\n        } else {\n            None\n        };\n\n        let (auth_url, csrf_token, nonce) = auth_req.url();\n\n        Ok(SsoLoginRequest {\n            auth_url,\n            csrf_token,\n            nonce,\n            pkce_verifier,\n            redirect_url,\n            config: self.config.clone(),\n        })\n    }\n\n    pub async fn finish_login(\n        &self,\n        pkce_verifier: Option<PkceCodeVerifier>,\n        redirect_url: RedirectUrl,\n        nonce: &Nonce,\n        code: String,\n    ) -> Result<SsoResult, SsoError> {\n        let client = make_client(&self.config, &self.http_client)\n            .await?\n            .set_redirect_uri(redirect_url);\n\n        let mut req = client.exchange_code(AuthorizationCode::new(code))?;\n        if let Some(verifier) = pkce_verifier {\n            req = req.set_pkce_verifier(verifier);\n        }\n\n        let token_response = req\n            .request_async(&self.http_client)\n            .await\n            .map_err(|e| match e {\n                RequestTokenError::ServerResponse(response) => {\n                    SsoError::Verification(response.error().to_string())\n                }\n                RequestTokenError::Parse(err, path) => SsoError::Verification(format!(\n                    \"Parse error: {:?} / {:?}\",\n                    err,\n                    String::from_utf8_lossy(&path)\n                )),\n                e => SsoError::Verification(format!(\"{e}\")),\n            })?;\n\n        let mut token_verifier = client.id_token_verifier();\n\n        if let Some(trusted_audiences) = self.config.additional_trusted_audiences() {\n            token_verifier = token_verifier\n                .set_other_audience_verifier_fn(|aud| trusted_audiences.contains(aud.deref()));\n        }\n\n        if self.config.trust_unknown_audiences() {\n            token_verifier = token_verifier.set_other_audience_verifier_fn(|_aud| true);\n        }\n\n        let id_token: &CoreIdToken = token_response.id_token().ok_or(SsoError::NotOidc)?;\n        let claims = id_token.claims(&token_verifier, nonce)?;\n\n        let user_info_req = client\n            .user_info(token_response.access_token().to_owned(), None)\n            .map_err(|err| {\n                error!(\"Failed to fetch userinfo: {err:?}\");\n                err\n            })\n            .ok();\n\n        let userinfo_claims: Option<UserInfoClaims<WarpgateClaims, CoreGenderClaim>> =\n            OptionFuture::from(user_info_req.map(|req| req.request_async(&self.http_client)))\n                .await\n                .and_then(|res| {\n                    res.map_err(|err| {\n                        error!(\"Failed to fetch userinfo: {err:?}\");\n                        err\n                    })\n                    .ok()\n                });\n\n        if let Some(expected_access_token_hash) = claims.access_token_hash() {\n            let actual_access_token_hash = AccessTokenHash::from_token(\n                token_response.access_token(),\n                id_token.signing_alg()?,\n                id_token.signing_key(&token_verifier)?,\n            )?;\n            if actual_access_token_hash != *expected_access_token_hash {\n                return Err(SsoError::Mitm);\n            }\n        }\n\n        Ok(SsoResult {\n            token: id_token.clone(),\n            userinfo_claims,\n            claims: claims.clone(),\n        })\n    }\n\n    pub async fn logout(&self, token: CoreIdToken, redirect_url: Url) -> Result<Url, SsoError> {\n        let metadata = discover_metadata(&self.config, &self.http_client).await?;\n        let Some(ref url) = metadata.additional_metadata().end_session_endpoint else {\n            return Err(SsoError::LogoutNotSupported);\n        };\n        let mut req: LogoutRequest = url.clone().into();\n        req = req.set_id_token_hint(&token);\n        req = req.set_client_id(self.config.client_id().clone());\n        req = req.set_post_logout_redirect_uri(PostLogoutRedirectUrl::from_url(redirect_url));\n        Ok(req.http_get_url())\n    }\n}\n"
  },
  {
    "path": "warpgate-tls/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-tls\"\nversion = \"0.22.0\"\n\n[dependencies]\nonce_cell = { version = \"1.17\", default-features = false }\npoem.workspace = true\npoem-openapi.workspace = true\nrustls-native-certs = { version = \"0.8\", default-features = false }\nrustls-pki-types.workspace = true\nrustls.workspace = true\nsea-orm.workspace = true\nserde_json.workspace = true\nserde.workspace = true\nthiserror.workspace = true\ntokio-rustls.workspace = true\ntokio.workspace = true\ntracing.workspace = true\nwebpki = { version = \"0.22\", default-features = false }\nx509-parser = \"0.17.0\"\n"
  },
  {
    "path": "warpgate-tls/src/cert.rs",
    "content": "use std::net::{Ipv4Addr, Ipv6Addr};\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\n\nuse poem::listener::RustlsCertificate;\nuse rustls::pki_types::{CertificateDer, PrivateKeyDer};\nuse rustls::server::ResolvesServerCert;\nuse rustls::sign::{CertifiedKey, SigningKey};\nuse rustls_pki_types::pem::PemObject;\nuse tokio::fs::File;\nuse tokio::io::AsyncReadExt;\nuse x509_parser::prelude::{FromDer, GeneralName, ParsedExtension, X509Certificate};\n\nuse crate::RustlsSetupError;\n\n#[derive(Debug, Clone)]\npub struct TlsCertificateBundle {\n    bytes: Vec<u8>,\n    certificates: Vec<CertificateDer<'static>>,\n}\n\n#[derive(Debug, Clone)]\npub struct TlsPrivateKey {\n    bytes: Vec<u8>,\n    key: Arc<dyn SigningKey>,\n}\n\nimpl TlsPrivateKey {\n    pub fn key(&self) -> &Arc<dyn SigningKey> {\n        &self.key\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct TlsCertificateAndPrivateKey {\n    pub certificate: TlsCertificateBundle,\n    pub private_key: TlsPrivateKey,\n}\n\nimpl TlsCertificateBundle {\n    pub fn bytes(&self) -> &[u8] {\n        &self.bytes\n    }\n\n    pub fn certificates(&self) -> &[CertificateDer<'static>] {\n        &self.certificates\n    }\n\n    pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, RustlsSetupError> {\n        let mut file = File::open(path).await?;\n        let mut bytes = Vec::new();\n        file.read_to_end(&mut bytes).await?;\n        Self::from_bytes(bytes)\n    }\n\n    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, RustlsSetupError> {\n        let certificates = CertificateDer::pem_slice_iter(&bytes[..])\n            .collect::<Result<Vec<CertificateDer<'static>>, _>>()?;\n\n        if certificates.is_empty() {\n            return Err(RustlsSetupError::NoCertificates);\n        }\n\n        Ok(Self {\n            bytes,\n            certificates,\n        })\n    }\n\n    pub fn sni_names(&self) -> Result<Vec<String>, RustlsSetupError> {\n        // Parse leaf certificate\n        let Some(cert_der) = self.certificates.first() else {\n            return Ok(Vec::new());\n        };\n\n        let (_, cert) =\n            X509Certificate::from_der(cert_der).map_err(|e| RustlsSetupError::X509(e.into()))?;\n\n        let mut names = Vec::new();\n\n        if let Some(san_ext) = cert\n            .extensions()\n            .iter()\n            .find(|ext| ext.oid == x509_parser::oid_registry::OID_X509_EXT_SUBJECT_ALT_NAME)\n        {\n            let san = san_ext.parsed_extension();\n            if let ParsedExtension::SubjectAlternativeName(san) = san {\n                for name in &san.general_names {\n                    match name {\n                        GeneralName::DNSName(dns_name) => {\n                            names.push(dns_name.to_string());\n                        }\n                        GeneralName::IPAddress(ip_bytes) => {\n                            if ip_bytes.len() == 4 {\n                                #[allow(clippy::unwrap_used)] // length checked\n                                names.push(\n                                    Ipv4Addr::from(<[u8; 4]>::try_from(*ip_bytes).unwrap())\n                                        .to_string(),\n                                );\n                            } else if ip_bytes.len() == 16 {\n                                #[allow(clippy::unwrap_used)] // length checked\n                                names.push(\n                                    Ipv6Addr::from(<[u8; 16]>::try_from(*ip_bytes).unwrap())\n                                        .to_string(),\n                                );\n                            }\n                        }\n                        _ => {}\n                    }\n                }\n            }\n        }\n\n        if let Some(subject) = cert.subject().iter_common_name().next() {\n            if let Ok(cn) = subject.as_str() {\n                names.push(cn.to_string());\n            }\n        }\n\n        // Remove duplicates while preserving order\n        let mut unique_names = Vec::new();\n        for name in names {\n            if !unique_names.contains(&name) {\n                unique_names.push(name);\n            }\n        }\n\n        Ok(unique_names)\n    }\n}\n\nimpl TlsPrivateKey {\n    pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, RustlsSetupError> {\n        let mut file = File::open(path).await?;\n        let mut bytes = Vec::new();\n        file.read_to_end(&mut bytes).await?;\n        Self::from_bytes(bytes)\n    }\n\n    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, RustlsSetupError> {\n        let key = PrivateKeyDer::from_pem_slice(bytes.as_slice())?;\n        let key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key)?;\n\n        Ok(Self { bytes, key })\n    }\n}\n\nimpl From<TlsCertificateBundle> for Vec<u8> {\n    fn from(val: TlsCertificateBundle) -> Self {\n        val.bytes\n    }\n}\n\nimpl From<TlsPrivateKey> for Vec<u8> {\n    fn from(val: TlsPrivateKey) -> Self {\n        val.bytes\n    }\n}\n\nimpl From<TlsCertificateAndPrivateKey> for RustlsCertificate {\n    fn from(val: TlsCertificateAndPrivateKey) -> Self {\n        RustlsCertificate::new()\n            .cert(val.certificate)\n            .key(val.private_key)\n    }\n}\n\nimpl From<TlsCertificateAndPrivateKey> for CertifiedKey {\n    fn from(val: TlsCertificateAndPrivateKey) -> Self {\n        let cert = val.certificate;\n        let key = val.private_key;\n        CertifiedKey {\n            cert: cert.certificates,\n            key: key.key,\n            ocsp: None,\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct SingleCertResolver(Arc<CertifiedKey>);\n\nimpl SingleCertResolver {\n    pub fn new(inner: TlsCertificateAndPrivateKey) -> Self {\n        Self(Arc::new(inner.into()))\n    }\n}\n\nimpl ResolvesServerCert for SingleCertResolver {\n    fn resolve(\n        &self,\n        _client_hello: rustls::server::ClientHello<'_>,\n    ) -> Option<Arc<rustls::sign::CertifiedKey>> {\n        Some(self.0.clone())\n    }\n}\n\npub trait IntoTlsCertificateRelativePaths {\n    fn certificate_path(&self) -> PathBuf;\n    fn key_path(&self) -> PathBuf;\n}\n"
  },
  {
    "path": "warpgate-tls/src/error.rs",
    "content": "use rustls::server::VerifierBuilderError;\nuse x509_parser::error::X509Error;\n\n#[derive(thiserror::Error, Debug)]\npub enum RustlsSetupError {\n    #[error(\"rustls: {0}\")]\n    Rustls(#[from] rustls::Error),\n    #[error(\"rustls: {0}\")]\n    RustlsPem(#[from] rustls_pki_types::pem::Error),\n    #[error(\"verifier setup: {0}\")]\n    VerifierBuilder(#[from] VerifierBuilderError),\n    #[error(\"no certificates found in certificate file\")]\n    NoCertificates,\n    #[error(\"no private keys found in key file\")]\n    NoKeys,\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n    #[error(\"PKI: {0}\")]\n    Pki(webpki::Error),\n    #[error(\"parsing certificate: {0}\")]\n    X509(#[from] X509Error),\n}\n"
  },
  {
    "path": "warpgate-tls/src/lib.rs",
    "content": "mod cert;\nmod error;\nmod maybe_tls_stream;\nmod mode;\nmod rustls_helpers;\nmod rustls_root_certs;\n\npub use cert::*;\npub use error::*;\npub use maybe_tls_stream::{MaybeTlsStream, MaybeTlsStreamError, UpgradableStream};\npub use mode::TlsMode;\npub use rustls_helpers::{configure_tls_connector, ResolveServerCert};\npub use rustls_root_certs::ROOT_CERT_STORE;\n"
  },
  {
    "path": "warpgate-tls/src/maybe_tls_stream.rs",
    "content": "use std::future::Future;\nuse std::pin::Pin;\nuse std::sync::Arc;\nuse std::task::Poll;\n\nuse rustls::pki_types::ServerName;\nuse rustls::{ClientConfig, ServerConfig};\nuse tokio::io::{AsyncRead, AsyncWrite, ReadBuf};\n\n#[derive(thiserror::Error, Debug)]\npub enum MaybeTlsStreamError {\n    #[error(\"stream is already upgraded\")]\n    AlreadyUpgraded,\n    #[error(\"I/O: {0}\")]\n    Io(#[from] std::io::Error),\n}\n\npub trait UpgradableStream<T>\nwhere\n    Self: Sized,\n    T: AsyncRead + AsyncWrite + Unpin,\n{\n    type UpgradeConfig;\n    fn upgrade(\n        self,\n        config: Self::UpgradeConfig,\n    ) -> impl Future<Output = Result<T, MaybeTlsStreamError>> + Send;\n}\n\npub enum MaybeTlsStream<S, TS>\nwhere\n    S: AsyncRead + AsyncWrite + Unpin + UpgradableStream<TS>,\n    TS: AsyncRead + AsyncWrite + Unpin,\n{\n    Tls(TS),\n    Raw(S),\n    Upgrading,\n}\n\nimpl<S, TS> MaybeTlsStream<S, TS>\nwhere\n    S: AsyncRead + AsyncWrite + Unpin + UpgradableStream<TS>,\n    TS: AsyncRead + AsyncWrite + Unpin,\n{\n    pub fn new(stream: S) -> Self {\n        Self::Raw(stream)\n    }\n}\n\nimpl<S, TS> MaybeTlsStream<S, TS>\nwhere\n    S: AsyncRead + AsyncWrite + Unpin + UpgradableStream<TS>,\n    TS: AsyncRead + AsyncWrite + Unpin,\n{\n    pub async fn upgrade(\n        mut self,\n        tls_config: S::UpgradeConfig,\n    ) -> Result<Self, MaybeTlsStreamError> {\n        if let Self::Raw(stream) = std::mem::replace(&mut self, Self::Upgrading) {\n            let stream = stream.upgrade(tls_config).await?;\n            Ok(MaybeTlsStream::Tls(stream))\n        } else {\n            Err(MaybeTlsStreamError::AlreadyUpgraded)\n        }\n    }\n}\n\nimpl<S, TS> AsyncRead for MaybeTlsStream<S, TS>\nwhere\n    S: AsyncRead + AsyncWrite + Unpin + UpgradableStream<TS>,\n    TS: AsyncRead + AsyncWrite + Unpin,\n{\n    fn poll_read(\n        self: Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n        buf: &mut ReadBuf<'_>,\n    ) -> Poll<tokio::io::Result<()>> {\n        match self.get_mut() {\n            MaybeTlsStream::Tls(tls) => Pin::new(tls).poll_read(cx, buf),\n            MaybeTlsStream::Raw(stream) => Pin::new(stream).poll_read(cx, buf),\n            _ => unreachable!(),\n        }\n    }\n}\n\nimpl<S, TS> AsyncWrite for MaybeTlsStream<S, TS>\nwhere\n    S: AsyncRead + AsyncWrite + Unpin + UpgradableStream<TS>,\n    TS: AsyncRead + AsyncWrite + Unpin,\n{\n    fn poll_write(\n        self: Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n        buf: &[u8],\n    ) -> std::task::Poll<std::io::Result<usize>> {\n        match self.get_mut() {\n            MaybeTlsStream::Tls(tls) => Pin::new(tls).poll_write(cx, buf),\n            MaybeTlsStream::Raw(stream) => Pin::new(stream).poll_write(cx, buf),\n            _ => unreachable!(),\n        }\n    }\n\n    fn poll_flush(\n        self: Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n    ) -> std::task::Poll<std::io::Result<()>> {\n        match self.get_mut() {\n            MaybeTlsStream::Tls(tls) => Pin::new(tls).poll_flush(cx),\n            MaybeTlsStream::Raw(stream) => Pin::new(stream).poll_flush(cx),\n            _ => unreachable!(),\n        }\n    }\n\n    fn poll_shutdown(\n        self: Pin<&mut Self>,\n        cx: &mut std::task::Context<'_>,\n    ) -> std::task::Poll<std::io::Result<()>> {\n        match self.get_mut() {\n            MaybeTlsStream::Tls(tls) => Pin::new(tls).poll_shutdown(cx),\n            MaybeTlsStream::Raw(stream) => Pin::new(stream).poll_shutdown(cx),\n            _ => unreachable!(),\n        }\n    }\n}\n\nimpl<S> UpgradableStream<tokio_rustls::client::TlsStream<S>> for S\nwhere\n    S: AsyncRead + AsyncWrite + Unpin + Send,\n{\n    type UpgradeConfig = (ServerName<'static>, Arc<ClientConfig>);\n\n    async fn upgrade(\n        self,\n        config: Self::UpgradeConfig,\n    ) -> Result<tokio_rustls::client::TlsStream<S>, MaybeTlsStreamError> {\n        let (domain, tls_config) = config;\n        let connector = tokio_rustls::TlsConnector::from(tls_config);\n        Ok(connector.connect(domain, self).await?)\n    }\n}\n\nimpl<S> UpgradableStream<tokio_rustls::server::TlsStream<S>> for S\nwhere\n    S: AsyncRead + AsyncWrite + Unpin + Send,\n{\n    type UpgradeConfig = Arc<ServerConfig>;\n\n    async fn upgrade(\n        self,\n        tls_config: Self::UpgradeConfig,\n    ) -> Result<tokio_rustls::server::TlsStream<S>, MaybeTlsStreamError> {\n        let acceptor = tokio_rustls::TlsAcceptor::from(tls_config);\n        Ok(acceptor.accept(self).await?)\n    }\n}\n"
  },
  {
    "path": "warpgate-tls/src/mode.rs",
    "content": "use poem_openapi::Enum;\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Deserialize, Serialize, Clone, Copy, Enum, PartialEq, Eq, Default)]\npub enum TlsMode {\n    #[serde(rename = \"disabled\")]\n    Disabled,\n    #[serde(rename = \"preferred\")]\n    #[default]\n    Preferred,\n    #[serde(rename = \"required\")]\n    Required,\n}\n\nimpl From<&str> for TlsMode {\n    fn from(s: &str) -> Self {\n        match s {\n            \"Disabled\" => TlsMode::Disabled,\n            \"Preferred\" => TlsMode::Preferred,\n            \"Required\" => TlsMode::Required,\n            _ => TlsMode::Preferred,\n        }\n    }\n}\n\nimpl From<TlsMode> for String {\n    fn from(mode: TlsMode) -> Self {\n        match mode {\n            TlsMode::Disabled => \"Disabled\".to_string(),\n            TlsMode::Preferred => \"Preferred\".to_string(),\n            TlsMode::Required => \"Required\".to_string(),\n        }\n    }\n}\n"
  },
  {
    "path": "warpgate-tls/src/rustls_helpers.rs",
    "content": "use std::io::Cursor;\nuse std::sync::Arc;\n\nuse rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};\nuse rustls::client::WebPkiServerVerifier;\nuse rustls::pki_types::{CertificateDer, ServerName, UnixTime};\nuse rustls::server::{ClientHello, ResolvesServerCert};\nuse rustls::sign::CertifiedKey;\nuse rustls::{CertificateError, ClientConfig, Error as TlsError, SignatureScheme};\nuse rustls_pki_types::pem::PemObject;\n\nuse super::{RustlsSetupError, ROOT_CERT_STORE};\n\n#[derive(Debug)]\npub struct ResolveServerCert(pub Arc<CertifiedKey>);\n\nimpl ResolvesServerCert for ResolveServerCert {\n    fn resolve(&self, _: ClientHello) -> Option<Arc<CertifiedKey>> {\n        Some(self.0.clone())\n    }\n}\n\npub async fn configure_tls_connector(\n    accept_invalid_certs: bool,\n    accept_invalid_hostnames: bool,\n    root_cert: Option<&[u8]>,\n) -> Result<ClientConfig, RustlsSetupError> {\n    let config = ClientConfig::builder_with_provider(Arc::new(\n        rustls::crypto::aws_lc_rs::default_provider(),\n    ))\n    .with_safe_default_protocol_versions()?;\n\n    let config = if accept_invalid_certs {\n        config\n            .dangerous()\n            .with_custom_certificate_verifier(Arc::new(DummyTlsVerifier))\n            .with_no_client_auth()\n    } else {\n        let mut cert_store = ROOT_CERT_STORE.clone();\n\n        if let Some(data) = root_cert {\n            let mut cursor = Cursor::new(data);\n\n            for cert in CertificateDer::pem_reader_iter(&mut cursor) {\n                cert_store.add(cert?)?;\n            }\n        }\n\n        if accept_invalid_hostnames {\n            let verifier = WebPkiServerVerifier::builder(Arc::new(cert_store)).build()?;\n\n            config\n                .dangerous()\n                .with_custom_certificate_verifier(Arc::new(NoHostnameTlsVerifier { verifier }))\n                .with_no_client_auth()\n        } else {\n            config\n                .with_root_certificates(cert_store)\n                .with_no_client_auth()\n        }\n    };\n\n    Ok(config)\n}\n\n#[derive(Debug)]\npub struct DummyTlsVerifier;\n\nimpl ServerCertVerifier for DummyTlsVerifier {\n    fn verify_server_cert(\n        &self,\n        _end_entity: &CertificateDer<'_>,\n        _intermediates: &[CertificateDer<'_>],\n        _server_name: &ServerName<'_>,\n        _ocsp_response: &[u8],\n        _now: UnixTime,\n    ) -> Result<ServerCertVerified, rustls::Error> {\n        Ok(ServerCertVerified::assertion())\n    }\n\n    fn verify_tls12_signature(\n        &self,\n        _message: &[u8],\n        _cert: &CertificateDer<'_>,\n        _dss: &rustls::DigitallySignedStruct,\n    ) -> Result<rustls::client::danger::HandshakeSignatureValid, TlsError> {\n        Ok(HandshakeSignatureValid::assertion())\n    }\n\n    fn verify_tls13_signature(\n        &self,\n        _message: &[u8],\n        _cert: &CertificateDer<'_>,\n        _dss: &rustls::DigitallySignedStruct,\n    ) -> Result<HandshakeSignatureValid, TlsError> {\n        Ok(HandshakeSignatureValid::assertion())\n    }\n\n    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {\n        vec![\n            SignatureScheme::RSA_PKCS1_SHA1,\n            SignatureScheme::ECDSA_SHA1_Legacy,\n            SignatureScheme::RSA_PKCS1_SHA256,\n            SignatureScheme::ECDSA_NISTP256_SHA256,\n            SignatureScheme::RSA_PKCS1_SHA384,\n            SignatureScheme::ECDSA_NISTP384_SHA384,\n            SignatureScheme::RSA_PKCS1_SHA512,\n            SignatureScheme::ECDSA_NISTP521_SHA512,\n            SignatureScheme::RSA_PSS_SHA256,\n            SignatureScheme::RSA_PSS_SHA384,\n            SignatureScheme::RSA_PSS_SHA512,\n            SignatureScheme::ED25519,\n            SignatureScheme::ED448,\n        ]\n    }\n}\n\n#[derive(Debug)]\npub struct NoHostnameTlsVerifier {\n    verifier: Arc<WebPkiServerVerifier>,\n}\n\nimpl ServerCertVerifier for NoHostnameTlsVerifier {\n    fn verify_server_cert(\n        &self,\n        end_entity: &CertificateDer<'_>,\n        intermediates: &[CertificateDer<'_>],\n        server_name: &ServerName<'_>,\n        ocsp_response: &[u8],\n        now: UnixTime,\n    ) -> Result<ServerCertVerified, rustls::Error> {\n        match self.verifier.verify_server_cert(\n            end_entity,\n            intermediates,\n            server_name,\n            ocsp_response,\n            now,\n        ) {\n            Err(TlsError::InvalidCertificate(CertificateError::NotValidForName)) => {\n                Ok(ServerCertVerified::assertion())\n            }\n            res => res,\n        }\n    }\n\n    fn verify_tls12_signature(\n        &self,\n        message: &[u8],\n        cert: &CertificateDer<'_>,\n        dss: &rustls::DigitallySignedStruct,\n    ) -> Result<HandshakeSignatureValid, TlsError> {\n        self.verifier.verify_tls12_signature(message, cert, dss)\n    }\n\n    fn verify_tls13_signature(\n        &self,\n        message: &[u8],\n        cert: &CertificateDer<'_>,\n        dss: &rustls::DigitallySignedStruct,\n    ) -> Result<HandshakeSignatureValid, TlsError> {\n        self.verifier.verify_tls13_signature(message, cert, dss)\n    }\n\n    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {\n        self.verifier.supported_verify_schemes()\n    }\n}\n"
  },
  {
    "path": "warpgate-tls/src/rustls_root_certs.rs",
    "content": "use once_cell::sync::Lazy;\nuse rustls::RootCertStore;\n\n#[allow(clippy::expect_used)]\npub static ROOT_CERT_STORE: Lazy<RootCertStore> = Lazy::new(|| {\n    let mut roots = RootCertStore::empty();\n    for cert in\n        rustls_native_certs::load_native_certs().expect(\"could not load root TLS certificates\")\n    {\n        roots.add(cert).expect(\"could not add root TLS certificate\");\n    }\n    roots\n});\n"
  },
  {
    "path": "warpgate-web/.editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 4\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nend_of_line = lf\n"
  },
  {
    "path": "warpgate-web/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n#---\n\napi-client\n"
  },
  {
    "path": "warpgate-web/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nname = \"warpgate-web\"\nversion = \"0.22.0\"\n\n[dependencies]\nserde.workspace = true\nrust-embed = { version = \"8.3\", default-features = false }\nserde_json.workspace = true\nthiserror.workspace = true\n"
  },
  {
    "path": "warpgate-web/eslint.config.mjs",
    "content": "// eslint.config.cjs\n\nimport globals from \"globals\";\nimport eslintPluginSvelte from 'eslint-plugin-svelte';\nimport js from '@eslint/js';\nimport svelteParser from 'svelte-eslint-parser';\nimport tsEslint from 'typescript-eslint';\nimport tsParser from '@typescript-eslint/parser';\nimport stylistic from '@stylistic/eslint-plugin'\n\nexport default [\n  js.configs.recommended,\n  ...tsEslint.configs.strict,\n  ...eslintPluginSvelte.configs['flat/recommended'],\n  {\n    ignores: [\"**/svelte.config.js\", \"**/vite.config.ts\", \"src/*/lib/api-client/**/*\"],\n  },\n  {\n    plugins: {\n        '@stylistic': stylistic,\n    },\n    languageOptions: {\n      parserOptions: {\n        projectService: true,\n        tsconfigRootDir: import.meta.dirname,\n        parser: tsParser,\n        extraFileExtensions: [\".svelte\"],\n      },\n      globals: {\n          ...globals.browser,\n          T: false,\n      },\n    },\n    rules: {\n        \"@stylistic/semi\": [\"error\", \"never\"],\n        \"@stylistic/indent\": [\"error\", 4],\n\n        \"@typescript-eslint/explicit-member-accessibility\": [\"error\", {\n            accessibility: \"no-public\",\n\n            overrides: {\n                parameterProperties: \"explicit\",\n            },\n        }],\n\n        \"@typescript-eslint/no-require-imports\": \"off\",\n        \"@typescript-eslint/no-parameter-properties\": \"off\",\n        \"@typescript-eslint/explicit-function-return-type\": \"off\",\n        \"@typescript-eslint/no-explicit-any\": \"off\",\n        \"@typescript-eslint/no-magic-numbers\": \"off\",\n        \"@typescript-eslint/member-delimiter-style\": \"off\",\n        \"@typescript-eslint/promise-function-async\": \"off\",\n        \"@typescript-eslint/require-array-sort-compare\": \"off\",\n        \"@typescript-eslint/no-floating-promises\": \"off\",\n        \"@typescript-eslint/prefer-readonly\": \"off\",\n        \"@typescript-eslint/require-await\": \"off\",\n        \"@typescript-eslint/strict-boolean-expressions\": \"off\",\n        \"@typescript-eslint/explicit-module-boundary-types\": \"error\",\n\n        // \"@typescript-eslint/no-misused-promises\": [\"error\", {\n        //     checksVoidReturn: false,\n        // }],\n\n        \"@typescript-eslint/typedef\": \"off\",\n        \"@typescript-eslint/consistent-type-imports\": \"off\",\n        \"@typescript-eslint/sort-type-union-intersection-members\": \"off\",\n\n        \"@typescript-eslint/no-use-before-define\": [\"error\", {\n            classes: false,\n            functions: false,\n        }],\n\n        \"no-duplicate-imports\": \"error\",\n        \"array-bracket-spacing\": [\"error\", \"never\"],\n        \"block-scoped-var\": \"error\",\n        \"brace-style\": \"off\",\n\n        \"@stylistic/brace-style\": [\"error\", \"1tbs\", {\n            allowSingleLine: true,\n        }],\n\n        \"computed-property-spacing\": [\"error\", \"never\"],\n        curly: \"error\",\n        \"eol-last\": \"error\",\n        eqeqeq: [\"error\", \"smart\"],\n        \"max-depth\": [1, 5],\n        \"max-statements\": [1, 80],\n        \"no-multiple-empty-lines\": \"error\",\n        \"no-mixed-spaces-and-tabs\": \"error\",\n        \"no-trailing-spaces\": \"error\",\n\n        \"@typescript-eslint/no-unused-vars\": [\"error\", {\n            vars: \"all\",\n            args: \"after-used\",\n            argsIgnorePattern: \"^_\",\n        }],\n\n        \"no-undef\": \"error\",\n        \"no-var\": \"error\",\n        \"object-curly-spacing\": \"off\",\n        \"@stylistic/object-curly-spacing\": [\"error\", \"always\"],\n\n        \"quote-props\": [\"warn\", \"as-needed\", {\n            keywords: true,\n            numbers: true,\n        }],\n\n        quotes: \"off\",\n\n        \"@stylistic/quotes\": [\"error\", \"single\", {\n            allowTemplateLiterals: 'always',\n        }],\n\n        \"@typescript-eslint/no-confusing-void-expression\": [\"error\", {\n            ignoreArrowShorthand: true,\n        }],\n\n        \"@typescript-eslint/no-non-null-assertion\": \"off\",\n\n        // \"@typescript-eslint/no-unnecessary-condition\": [\"error\", {\n        //     allowConstantLoopConditions: true,\n        // }],\n\n        \"@typescript-eslint/restrict-template-expressions\": \"off\",\n        \"@typescript-eslint/prefer-readonly-parameter-types\": \"off\",\n        \"@typescript-eslint/no-unsafe-member-access\": \"off\",\n        \"@typescript-eslint/no-unsafe-call\": \"off\",\n        \"@typescript-eslint/no-unsafe-return\": \"off\",\n        \"@typescript-eslint/no-unsafe-assignment\": \"off\",\n        \"@typescript-eslint/naming-convention\": \"off\",\n\n        \"@stylistic/lines-between-class-members\": [\"error\", \"always\", {\n            exceptAfterSingleLine: true,\n        }],\n\n        \"@typescript-eslint/dot-notation\": \"off\",\n        \"@typescript-eslint/no-implicit-any-catch\": \"off\",\n        \"@typescript-eslint/member-ordering\": \"off\",\n        \"@typescript-eslint/no-var-requires\": \"off\",\n        \"@typescript-eslint/no-unsafe-argument\": \"off\",\n        \"@typescript-eslint/restrict-plus-operands\": \"off\",\n        \"@typescript-eslint/space-infix-ops\": \"off\",\n\n        \"@typescript-eslint/no-type-alias\": [\"error\", {\n            allowAliases: \"in-unions-and-intersections\",\n            allowLiterals: \"always\",\n            allowCallbacks: \"always\",\n        }],\n\n        \"@stylistic/comma-dangle\": [\"error\", {\n            arrays: \"always-multiline\",\n            objects: \"always-multiline\",\n            imports: \"always-multiline\",\n            exports: \"always-multiline\",\n            functions: \"only-multiline\",\n        }],\n\n        \"@typescript-eslint/use-unknown-in-catch-callback-variable\": \"off\",\n    },\n  },\n  {\n    files: ['**/*.svelte'],\n    languageOptions: {\n      parser: svelteParser,\n      parserOptions: {\n        projectService: true,\n        tsconfigRootDir: import.meta.dirname,\n        parser: tsParser,\n      },\n    },\n    rules: {\n      'svelte/no-target-blank': 'error',\n      'svelte/no-at-debug-tags': 'error',\n      'svelte/no-reactive-functions': 'error',\n      'svelte/no-reactive-literals': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "warpgate-web/openapitools.json",
    "content": "{\n  \"$schema\": \"node_modules/@openapitools/openapi-generator-cli/config.schema.json\",\n  \"spaces\": 2,\n  \"generator-cli\": {\n    \"version\": \"7.7.0\"\n  }\n}\n"
  },
  {
    "path": "warpgate-web/package.json",
    "content": "{\n  \"name\": \"warpgate-admin\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"build\": \"vite build\",\n    \"devbuild\": \"vite build --mode development --minify false\",\n    \"watch\": \"vite build -w --mode development --minify false\",\n    \"check\": \"svelte-check --compiler-warnings 'a11y-no-noninteractive-element-interactions:ignore,a11y-click-events-have-key-events:ignore,a11y-no-static-element-interactions:ignore' --tsconfig ./tsconfig.json\",\n    \"lint\": \"eslint src && svelte-check\",\n    \"postinstall\": \"npm run openapi:client:gateway && npm run openapi:client:admin\",\n    \"openapi:schema:gateway\": \"cargo run -p warpgate-protocol-http > src/gateway/lib/openapi-schema.json\",\n    \"openapi:schema:admin\": \"cargo run -p warpgate-admin > src/admin/lib/openapi-schema.json\",\n    \"openapi:client:gateway\": \"openapi-generator-cli generate -g typescript-fetch -i src/gateway/lib/openapi-schema.json -o src/gateway/lib/api-client -p npmName=warpgate-gateway-api-client -p useSingleRequestParameter=true && cd src/gateway/lib/api-client && npm i typescript@5 && npm i && npx tsc --target esnext --module esnext && rm -rf src tsconfig.json\",\n    \"openapi:client:admin\": \"openapi-generator-cli generate -g typescript-fetch -i src/admin/lib/openapi-schema.json -o src/admin/lib/api-client -p npmName=warpgate-admin-api-client -p useSingleRequestParameter=true && cd src/admin/lib/api-client && npm i typescript@5 && npm i && npx tsc --target esnext --module esnext && rm -rf src tsconfig.json\",\n    \"openapi:tests-sdk\": \"openapi-generator-cli generate -g python -i src/admin/lib/openapi-schema.json -o ../tests/api_sdk\",\n    \"openapi\": \"npm run openapi:schema:admin && npm run openapi:schema:gateway && npm run openapi:client:admin && npm run openapi:client:gateway\"\n  },\n  \"devDependencies\": {\n    \"@fontsource/poppins\": \"^5.2.7\",\n    \"@fontsource/work-sans\": \"^5.2.8\",\n    \"@fortawesome/free-brands-svg-icons\": \"^7.2.0\",\n    \"@fortawesome/free-regular-svg-icons\": \"^7.2.0\",\n    \"@fortawesome/free-solid-svg-icons\": \"^7.2.0\",\n    \"@openapitools/openapi-generator-cli\": \"^2.30.2\",\n    \"@otplib/plugin-base32-enc-dec\": \"^12.0.1\",\n    \"@otplib/plugin-crypto-js\": \"^12.0.1\",\n    \"@otplib/preset-browser\": \"^12.0.1\",\n    \"@stylistic/eslint-plugin\": \"^5.10.0\",\n    \"@sveltejs/vite-plugin-svelte\": \"^6.2.4\",\n    \"@sveltestrap/sveltestrap\": \"^6.2.7\",\n    \"@tsconfig/svelte\": \"^5.0.8\",\n    \"@types/qrcode\": \"^1.5.6\",\n    \"@types/ua-parser-js\": \"^0.7.36\",\n    \"@xterm/addon-serialize\": \"^0.14\",\n    \"@xterm/xterm\": \"^5.5\",\n    \"bootstrap\": \"^5.3.8\",\n    \"copy-text-to-clipboard\": \"^3.2.2\",\n    \"date-fns\": \"^4.1.0\",\n    \"eslint\": \"^9\",\n    \"eslint-import-resolver-typescript\": \"^4.4.4\",\n    \"eslint-plugin-import\": \"^2.32.0\",\n    \"eslint-plugin-node\": \"^11.1.0\",\n    \"eslint-plugin-promise\": \"^6\",\n    \"eslint-plugin-svelte\": \"^3.15.2\",\n    \"format-duration\": \"^3.0.2\",\n    \"otpauth\": \"^9.5.0\",\n    \"qrcode\": \"^1.5.4\",\n    \"rxjs\": \"^7.8.2\",\n    \"sass\": \"1.78\",\n    \"svelte\": \"^5.54.0\",\n    \"svelte-check\": \"^4.4.5\",\n    \"svelte-fa\": \"^4.0.4\",\n    \"svelte-intersection-observer\": \"^1.1.1\",\n    \"svelte-observable\": \"^0.4.0\",\n    \"svelte-preprocess\": \"^6.0.3\",\n    \"svelte-spa-router\": \"^4.0.1\",\n    \"thenby\": \"^1.3.4\",\n    \"tslib\": \"^2.8.0\",\n    \"typescript\": \"^5.9.3\",\n    \"typescript-eslint\": \"^8.57.1\",\n    \"ua-parser-js\": \"^2.0.9\",\n    \"vite\": \"^7.3.1\",\n    \"vite-plugin-checker\": \"^0.12.0\",\n    \"vite-tsconfig-paths\": \"^6.1.1\"\n  },\n  \"overrides\": {\n    \"svelte-observable\": {\n      \"svelte\": \"^5\"\n    },\n    \"@eslint-community/eslint-utils\": {\n      \"eslint\": \"^9\"\n    }\n  },\n  \"dependencies\": {\n    \"natural-orderby\": \"^5.0.0\"\n  }\n}\n"
  },
  {
    "path": "warpgate-web/src/admin/App.svelte",
    "content": "<script lang=\"ts\">\n    import { serverInfo, reloadServerInfo } from 'gateway/lib/store'\n\n    import Router, { link, type WrappedComponent } from 'svelte-spa-router'\n    import active from 'svelte-spa-router/active'\n    import { wrap } from 'svelte-spa-router/wrap'\n    import ThemeSwitcher from 'common/ThemeSwitcher.svelte'\n    import AuthBar from 'common/AuthBar.svelte'\n    import Brand from 'common/Brand.svelte'\n    import Loadable from 'common/Loadable.svelte'\n\n    async function init () {\n        await reloadServerInfo()\n    }\n\n    const initPromise = init()\n\n    const routes: Record<string, WrappedComponent> = {\n        '/': wrap({\n            asyncComponent: () => import('./Home.svelte') as any,\n        }),\n        '/sessions/:id': wrap({\n            asyncComponent: () => import('./Session.svelte') as any,\n        }),\n        '/recordings/:id': wrap({\n            asyncComponent: () => import('./Recording.svelte') as any,\n        }),\n        '/log': wrap({\n            asyncComponent: () => import('./Log.svelte') as any,\n        }),\n        '/config': wrap({\n            asyncComponent: () => import('./config/Config.svelte') as any,\n        }),\n    }\n    routes['/config/*'] = routes['/config']!\n</script>\n\n<Loadable promise={initPromise}>\n    <div class=\"app container-lg\">\n        <header>\n            <a href=\"/@warpgate\" class=\"d-flex logo-link me-4\">\n                <Brand />\n            </a>\n            {#if $serverInfo?.username}\n                <a use:link use:active href=\"/\">Sessions</a>\n                <a use:link use:active href=\"/config\">Config</a>\n                <a use:link use:active href=\"/log\">Log</a>\n            {/if}\n            <span class=\"ms-3\"></span>\n            <div class=\"ms-auto\">\n                <AuthBar />\n            </div>\n        </header>\n        <main>\n            <Router {routes}/>\n        </main>\n\n        <footer class=\"mt-5\">\n            <span class=\"me-auto ms-3\">\n                {$serverInfo?.version}\n            </span>\n            <ThemeSwitcher />\n        </footer>\n    </div>\n</Loadable>\n\n<style lang=\"scss\">\n    @media (max-width: 767px) {\n        .logo-link {\n            display: none !important;\n        }\n    }\n\n    .app {\n        min-height: 100vh;\n        display: flex;\n        flex-direction: column;\n    }\n\n    header, footer {\n        flex: none;\n    }\n\n    main {\n        flex: 1 0 0;\n    }\n\n    header {\n        display: flex;\n        align-items: center;\n        padding: 7px 0;\n        margin: 10px 0 20px;\n\n        a {\n            font-size: 1.5rem;\n            margin-right: 15px;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/AuthPolicyEditor.svelte",
    "content": "<script lang=\"ts\">\nimport { Input } from '@sveltestrap/sveltestrap'\nimport { CredentialKind, type UserRequireCredentialsPolicy } from './lib/api'\nimport type { ExistingCredential } from './CredentialEditor.svelte'\nimport InfoBox from 'common/InfoBox.svelte'\nimport { SvelteSet } from 'svelte/reactivity'\n\ntype ProtocolID = 'http' | 'ssh' | 'mysql' | 'postgres' | 'kubernetes'\n\ninterface Props {\n    value: UserRequireCredentialsPolicy\n    possibleCredentials: Set<CredentialKind>\n    existingCredentials: ExistingCredential[]\n    protocolId: ProtocolID\n}\n\nlet {\n    value = $bindable(),\n    possibleCredentials,\n    existingCredentials,\n    protocolId,\n}: Props = $props()\n\nconst labels = {\n    Password: 'Password',\n    PublicKey: 'Key',\n    Certificate: 'Certificate',\n    Totp: 'OTP',\n    Sso: 'SSO',\n    WebUserApproval: 'In-browser auth',\n}\n\nconst tips: Record<ProtocolID, Map<[CredentialKind, boolean], string>> = {\n    postgres: new Map([\n        [\n            [CredentialKind.WebUserApproval, true],\n            'Not all clients will show the 2FA auth prompt. The user might need to log in to the Warpgate UI to see the prompt.',\n        ],\n    ]),\n    http: new Map(),\n    mysql: new Map(),\n    ssh: new Map(),\n    kubernetes: new Map([\n        [\n            [CredentialKind.WebUserApproval, true],\n            'Users will need to log in to the Warpgate UI to see the 2FA auth prompt for Kubernetes access.',\n        ],\n    ]),\n}\n\nlet activeTips: string[] = $derived.by(() => {\n    let result = []\n    for (const [[kind, enabled], tip] of tips[protocolId]?.entries() ?? []) {\n        if (value[protocolId]?.includes(kind) === enabled) {\n            result.push(tip)\n        }\n    }\n    return result\n})\n\nconst validCredentials = $derived.by(() => {\n    const vc = new SvelteSet(existingCredentials.map(x => x.kind as CredentialKind))\n    vc.add(CredentialKind.WebUserApproval)\n    return vc\n})\n\nlet isAny = $derived(!value[protocolId])\n\nfunction updateAny () {\n    if (isAny) {\n        value[protocolId] = undefined\n    } else {\n        value[protocolId] = []\n        let oneCred = Array.from(validCredentials).find(x => possibleCredentials.has(x))\n        if (oneCred) {\n            value[protocolId] = [oneCred]\n        }\n    }\n}\n\nfunction toggle (type: CredentialKind) {\n    if (value[protocolId]!.includes(type)) {\n        value[protocolId] = value[protocolId]!.filter((x: CredentialKind) => x !== type)\n    } else {\n        value[protocolId]!.push(type)\n    }\n}\n</script>\n\n<div class=\"d-flex wrapper\">\n    <Input\n        id={'policy-editor-' + protocolId}\n        type=\"switch\"\n        bind:checked={isAny}\n        label=\"Any credential\"\n        on:change={updateAny}\n    />\n    {#if !isAny}\n        {#each [...validCredentials] as type (type)}\n            {#if possibleCredentials.has(type)}\n                <Input\n                    id={'policy-editor-' + protocolId + type}\n                    type=\"switch\"\n                    checked={value[protocolId]?.includes(type)}\n                    label={labels[type]}\n                    on:change={() => toggle(type)}\n                />\n            {/if}\n        {/each}\n    {/if}\n</div>\n\n{#each activeTips as tip (tip)}\n    <InfoBox class=\"mt-2\">{tip}</InfoBox>\n{/each}\n\n<style lang=\"scss\">\n    .wrapper {\n        flex-wrap: wrap;\n        :global(.form-switch) {\n            margin-right: 1rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/CertificateCredentialModal.svelte",
    "content": "<script lang=\"ts\">\n    import { faCheck, faInfoCircle } from '@fortawesome/free-solid-svg-icons'\n    import {\n        Button,\n        FormGroup,\n        Input,\n        Modal,\n        ModalBody,\n        ModalFooter,\n    } from '@sveltestrap/sveltestrap'\n    import type { IssuedCertificateCredential } from 'admin/lib/api'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import CopyButton from 'common/CopyButton.svelte'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Fa from 'svelte-fa'\n\n    interface Props {\n        isOpen: boolean\n        username: string\n        save: (label: string, publicKeyPem: string) => Promise<IssuedCertificateCredential>\n        onClose?: () => void\n    }\n\n    let {\n        isOpen = $bindable(false),\n        username,\n        save,\n        onClose,\n    }: Props = $props()\n\n    let saving = $state(false)\n    let privateKeyPem = $state('')\n    let publicKeyPem = $state('')\n    let label = $state('')\n    let generatedCertificatePem = $state('')\n    let generatedKubeConfig = $state('')\n\n    async function generateKeyPair() {\n        try {\n            const keyPair = await crypto.subtle.generateKey(\n                {\n                    name: 'ECDSA',\n                    namedCurve: 'P-384',\n                },\n                true,\n                ['sign', 'verify']\n            )\n\n            // Export private key as PKCS#8 PEM\n            const privateKeyArrayBuffer = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey)\n            const privateKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(privateKeyArrayBuffer)))\n            const privateKeyLines = privateKeyBase64.match(/.{1,64}/g) || []\n            privateKeyPem = `-----BEGIN PRIVATE KEY-----\\n${privateKeyLines.join('\\n')}\\n-----END PRIVATE KEY-----`\n\n            // Export public key as SPKI PEM\n            const publicKeyArrayBuffer = await crypto.subtle.exportKey('spki', keyPair.publicKey)\n            const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(publicKeyArrayBuffer)))\n            const publicKeyLines = publicKeyBase64.match(/.{1,64}/g) || []\n            publicKeyPem = `-----BEGIN PUBLIC KEY-----\\n${publicKeyLines.join('\\n')}\\n-----END PUBLIC KEY-----`\n        } catch (error) {\n            console.error('Failed to generate key pair:', error)\n            alert('Failed to generate key pair. Please try again.')\n        }\n    }\n\n    async function _generate() {\n        if (!label.trim()) {\n            alert('Please provide a label')\n            return\n        }\n\n        saving = true\n        try {\n            await generateKeyPair()\n\n            if (!publicKeyPem) {\n                throw new Error('Failed to generate key pair')\n            }\n\n            // Then submit public key to get certificate issued\n            const result = await save(label.trim(), publicKeyPem)\n            generatedCertificatePem = result.certificatePem\n\n            generatedKubeConfig = `- name: ${username}\\n  user:\\n    client-certificate-data: ${btoa(generatedCertificatePem)}\\n    client-key-data: ${btoa(privateKeyPem)}`\n        } catch (error) {\n            console.error('Failed to generate certificate:', error)\n            alert('Failed to generate certificate. Please try again.')\n        } finally {\n            saving = false\n        }\n    }\n\n    function downloadBlob(content: string, filename: string) {\n        const blob = new Blob([content], { type: 'text/plain' })\n        const url = URL.createObjectURL(blob)\n        const a = document.createElement('a')\n        a.href = url\n        a.download = filename\n        document.body.appendChild(a)\n        a.click()\n        document.body.removeChild(a)\n        URL.revokeObjectURL(url)\n    }\n\n    function downloadPrivateKey() {\n        if (!privateKeyPem) {\n            return\n        }\n        const filename = label.trim() ? `${label.trim()}-private-key.pem` : 'private-key.pem'\n        downloadBlob(privateKeyPem, filename)\n    }\n\n    function downloadCertificate() {\n        if (!generatedCertificatePem) {\n            return\n        }\n        const certLabel = label.trim() || 'certificate'\n        const filename = `${certLabel}-certificate.pem`\n        downloadBlob(generatedCertificatePem, filename)\n    }\n\n    function close() {\n        isOpen = false\n        privateKeyPem = ''\n        publicKeyPem = ''\n        label = ''\n        generatedCertificatePem = ''\n        saving = false\n        onClose?.()\n    }\n</script>\n\n<Modal {isOpen} toggle={close}>\n    <ModalBody>\n        {#if generatedCertificatePem}\n            <div class=\"text-center mb-3\">\n                <Fa icon={faCheck} class=\"m-auto\" size=\"lg\" />\n                <p>Certificate has been issued</p>\n            </div>\n            <Alert color=\"warning\" fade={false} class=\"mb-3\">\n                You must download the private key and the certificate now - you won't be able to access them later.\n            </Alert>\n        {:else}\n            <FormGroup floating label=\"Certificate label\">\n                <Input\n                    bind:value={label}\n                    disabled={saving}\n                />\n            </FormGroup>\n\n            <div class=\"text-muted d-flex align-items-center\">\n                <Fa icon={faInfoCircle} class=\"me-3\" />\n                <small>\n                    A private key will be generated locally in your browser.\n                    <br/>\n                    You'll need to save it after the certificate is issued.\n                </small>\n            </div>\n        {/if}\n    </ModalBody>\n    <ModalFooter>\n        {#if !generatedCertificatePem}\n            <AsyncButton\n                color=\"primary\"\n                class=\"modal-button\"\n                disabled={saving || !label.trim()}\n                click={_generate}\n            >\n                Issue certificate\n            </AsyncButton>\n        {:else}\n            <Button\n                color=\"primary\"\n                class=\"d-block w-100\"\n                on:click={downloadCertificate}\n            >\n                Save certificate\n            </Button>\n        {/if}\n        {#if privateKeyPem}\n            <Button\n                color=\"primary\"\n                class=\"d-block w-100\"\n                on:click={downloadPrivateKey}\n            >\n                Save private key\n            </Button>\n        {/if}\n        {#if generatedKubeConfig}\n            <CopyButton\n                color=\"secondary\"\n                class=\"d-flex align-items-center justify-content-center w-100\"\n                text={generatedKubeConfig}\n                label=\"Copy both as kubeconfig\"\n                />\n        {/if}\n        <Button\n            color=\"danger\"\n            on:click={close}\n            class=\"modal-button\"\n        >Close</Button>\n    </ModalFooter>\n</Modal>\n"
  },
  {
    "path": "warpgate-web/src/admin/CreateOtpModal.svelte",
    "content": "<script lang=\"ts\">\n    import {\n        Button,\n        Form,\n        FormGroup,\n        Input,\n        Modal,\n        ModalBody,\n        ModalFooter,\n    } from '@sveltestrap/sveltestrap'\n\n    import QRCode from 'qrcode'\n    import * as OTPAuth from 'otpauth'\n    import base32Encode from 'base32-encode'\n    import Fa from 'svelte-fa'\n    import { faRefresh } from '@fortawesome/free-solid-svg-icons'\n    import CopyButton from 'common/CopyButton.svelte'\n\n    interface Props {\n        isOpen: boolean\n        username: string\n        create: (secretKey: number[]) => void\n    }\n\n    let {\n        isOpen = $bindable(true),\n        username,\n        create,\n    }: Props = $props()\n    let secretKey: number[] = $state([])\n    let qrImage: HTMLImageElement|undefined = $state()\n    let totpUri: string|undefined = $state()\n    let totpValidationValue: string|undefined = $state()\n    let field: HTMLInputElement|undefined = $state()\n    let validated = $state(false)\n    let totpValid = $state(false)\n    let validationFeedback = $state<string|undefined>()\n\n    const totp = $state(new OTPAuth.TOTP({\n        issuer: 'Warpgate',\n        digits: 6,\n        period: 30,\n        algorithm: 'SHA1',\n    }))\n\n    function _validate () : boolean {\n        if (totpValidationValue) {\n            totp.secret ??= OTPAuth.Secret.fromBase32(encodeTotpSecret(secretKey))\n            totpValid = totp.validate({ token: totpValidationValue, window: 1 }) !== null\n\n            if (!totpValid) {\n                validationFeedback = 'The TOTP code is not valid'\n            } else {\n                validationFeedback = undefined\n            }\n\n            return totpValid\n        }\n        return true\n    }\n\n    function generateNewTotpKey () {\n        secretKey = Array.from({ length: 32 }, () => Math.floor(Math.random() * 255))\n    }\n\n    /**\n    * Generates a TOTP (Time-based One-Time Password) secret key encoded in base32.\n    *\n    * @param {UserTotpCredential} cred - The credential containing a key for TOTP generation.\n    * @return {string} The base32 encoded TOTP secret key.\n    */\n    function encodeTotpSecret (secretKey: number[]) : string {\n        return base32Encode(new Uint8Array(secretKey), 'RFC4648')\n    }\n\n    $effect(() => {\n        if (!secretKey.length) {\n            generateNewTotpKey()\n        }\n\n        totp.label = username\n        totp.secret = OTPAuth.Secret.fromBase32(encodeTotpSecret(secretKey))\n        totpUri = totp.toString()\n\n        QRCode.toDataURL(totpUri, (err: Error | null | undefined, imageUrl: string) => {\n            if (err) {\n                return\n            }\n            if (qrImage) {\n                qrImage.src = imageUrl\n            }\n        })\n\n        _validate()\n    })\n\n    function _save () {\n        if (!secretKey) {\n            return\n        }\n        isOpen = false\n        create(secretKey)\n    }\n\n    function _cancel () {\n        isOpen = false\n    }\n</script>\n\n<Modal toggle={_cancel} isOpen={isOpen} on:open={() => field?.focus()}>\n    <Form {validated} on:submit={e => {\n        _save()\n        e.preventDefault()\n    }}>\n        <ModalBody>\n            <img class=\"qr mb-3\" bind:this={qrImage} alt=\"OTP QR code\" />\n\n            <div class=\"d-flex justify-content-center mb-4\">\n                <Button class=\"d-flex align-items-center me-3\" on:click={generateNewTotpKey}>\n                    <Fa class=\"me-2\" fw icon={faRefresh} />\n                    Regenerate\n                </Button>\n                <CopyButton class=\"d-flex align-items-center\" color=\"secondary\" text={totpUri!} label='Copy URI' />\n            </div>\n            <FormGroup floating label=\"Paste the generated OTP code\" class=\"mt-3\" spacing=\"0\">\n                <Input\n                    required\n                    bind:feedback={validationFeedback}\n                    bind:value={totpValidationValue}\n                    valid={totpValid}\n                    invalid={!totpValid}\n                    pattern={'\\\\d{6}'}\n                    inputmode=\"numeric\"\n                    on:change={_validate}\n                    on:keyup={_validate} />\n            </FormGroup>\n        </ModalBody>\n        <ModalFooter>\n            <Button\n                class=\"modal-button\"\n                color=\"primary\"\n                disabled={!totpValid}\n                on:click={() => validated = true}\n            >Create</Button>\n\n            <Button\n                class=\"modal-button\"\n                color=\"danger\"\n                on:click={_cancel}\n            >Cancel</Button>\n        </ModalFooter>\n    </Form>\n</Modal>\n\n<style lang=\"scss\">\n    .qr {\n        border-radius: 5px;\n        max-width: 200px;\n        margin: auto;\n        display: block;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/CreatePasswordModal.svelte",
    "content": "<script lang=\"ts\">\n    import {\n        Button,\n        Form,\n        FormGroup,\n        Input,\n        Modal,\n        ModalBody,\n        ModalFooter,\n    } from '@sveltestrap/sveltestrap'\n\n    interface Props {\n        isOpen: boolean\n        create: (password: string) => void\n    }\n\n    let {\n        isOpen = $bindable(true),\n        create,\n    }: Props = $props()\n    let password = $state('')\n    let field: HTMLInputElement|undefined = $state()\n    let validated = $state(false)\n\n    function _save () {\n        if (!password) {\n            return\n        }\n        isOpen = false\n        create(password)\n        password = ''\n    }\n\n    function _cancel () {\n        isOpen = false\n        password = ''\n    }\n</script>\n\n<Modal toggle={_cancel} isOpen={isOpen} on:open={() => field?.focus()}>\n    <Form {validated} on:submit={e => {\n        _save()\n        e.preventDefault()\n    }}>\n        <ModalBody>\n            <FormGroup floating label=\"Enter a new password\" spacing=\"0\">\n                <Input\n                    bind:inner={field}\n                    type=\"password\"\n                    placeholder=\"New password\"\n                    required\n                    bind:value={password} />\n            </FormGroup>\n        </ModalBody>\n        <ModalFooter>\n            <Button\n                class=\"modal-button\"\n                color=\"primary\"\n                on:click={() => validated = true}\n            >Create</Button>\n\n            <Button\n                class=\"modal-button\"\n                color=\"danger\"\n                on:click={_cancel}\n            >Cancel</Button>\n        </ModalFooter>\n    </Form>\n</Modal>\n"
  },
  {
    "path": "warpgate-web/src/admin/CredentialEditor.svelte",
    "content": "<script lang=\"ts\" module>\n    export type ExistingCredential =\n        { kind: typeof CredentialKind.Password } & ExistingPasswordCredential\n        | { kind: typeof CredentialKind.Sso } & ExistingSsoCredential\n        | { kind: typeof CredentialKind.PublicKey } & ExistingPublicKeyCredential\n        | { kind: typeof CredentialKind.Certificate } & ExistingCertificateCredential\n        | { kind: typeof CredentialKind.Totp } & ExistingOtpCredential\n</script>\n\n<script lang=\"ts\">\n    import { faCertificate, faIdBadge, faKey, faKeyboard, faMobileScreen } from '@fortawesome/free-solid-svg-icons'\n    import { api, CredentialKind, type ExistingPasswordCredential, type ExistingPublicKeyCredential, type ExistingSsoCredential, type ExistingOtpCredential, type UserRequireCredentialsPolicy, type ParameterValues, type ExistingCertificateCredential } from 'admin/lib/api'\n    import { SvelteSet } from 'svelte/reactivity'\n    import Fa from 'svelte-fa'\n    import { Button } from '@sveltestrap/sveltestrap'\n    import CreatePasswordModal from './CreatePasswordModal.svelte'\n    import SsoCredentialModal from './SsoCredentialModal.svelte'\n    import PublicKeyCredentialModal from './PublicKeyCredentialModal.svelte'\n    import CertificateCredentialModal from './CertificateCredentialModal.svelte'\n    import CreateOtpModal from './CreateOtpModal.svelte'\n    import AuthPolicyEditor from './AuthPolicyEditor.svelte'\n    import { abbreviatePublicKey, possibleCredentials } from 'common/protocols'\n    import CredentialUsedStateBadge from 'common/CredentialUsedStateBadge.svelte'\n    import Loadable from 'common/Loadable.svelte'\n    import EmptyState from 'common/EmptyState.svelte'\n    import Tooltip from 'common/sveltestrap-s5-ports/Tooltip.svelte'\n    import { adminPermissions } from './lib/store'\n\n    interface Props {\n        userId: string\n        username: string\n        credentialPolicy: UserRequireCredentialsPolicy,\n        ldapLinked?: boolean\n    }\n    let { userId, username, credentialPolicy = $bindable(), ldapLinked = false }: Props = $props()\n\n    let credentials: ExistingCredential[] = $state([])\n    let globalParameters: ParameterValues | undefined = $state()\n\n    let creatingPassword = $state(false)\n    let creatingOtp = $state(false)\n    let editingSsoCredential = $state(false)\n    let editingSsoCredentialInstance: ExistingSsoCredential|null = $state(null)\n    let editingPublicKeyCredential = $state(false)\n    let editingPublicKeyCredentialInstance: ExistingPublicKeyCredential|null = $state(null)\n    let editingCertificateCredential = $state(false)\n\n    const loadPromise = load()\n\n    const policyProtocols: { id: 'ssh' | 'http' | 'mysql' | 'postgres' | 'kubernetes', name: string }[] = [\n        { id: 'ssh', name: 'SSH' },\n        { id: 'http', name: 'HTTP' },\n        { id: 'mysql', name: 'MySQL' },\n        { id: 'postgres', name: 'PostgreSQL' },\n        { id: 'kubernetes', name: 'Kubernetes' },\n    ]\n\n    // Get effective possible credentials for a protocol, considering global SSH auth settings\n    function getEffectivePossibleCredentials(protocolId: string): SvelteSet<CredentialKind> {\n        const base = possibleCredentials[protocolId]\n        if (!base) {\n            return new SvelteSet()\n        }\n\n        // For SSH, filter based on global auth method settings\n        if (protocolId === 'ssh' && globalParameters) {\n            const filtered = new SvelteSet<CredentialKind>()\n            for (const kind of base) {\n                // PublicKey requires publickey auth enabled\n                if (kind === CredentialKind.PublicKey && !globalParameters.sshClientAuthPublickey) {\n                    continue\n                }\n                // Password requires password auth enabled\n                if (kind === CredentialKind.Password && !globalParameters.sshClientAuthPassword) {\n                    continue\n                }\n                // Totp and WebUserApproval require keyboard-interactive auth enabled\n                if ((kind === CredentialKind.Totp || kind === CredentialKind.WebUserApproval) && !globalParameters.sshClientAuthKeyboardInteractive) {\n                    continue\n                }\n                filtered.add(kind)\n            }\n            return filtered\n        }\n\n        return new SvelteSet(base)\n    }\n\n    async function load () {\n        await Promise.all([\n            loadPasswords(),\n            loadSso(),\n            loadPublicKeys(),\n            loadCertificates(),\n            loadOtp(),\n            loadParameters(),\n        ])\n    }\n\n    async function loadParameters () {\n        globalParameters = await api.getParameters({})\n    }\n\n    async function loadPasswords () {\n        credentials.push(...(await api.getPasswordCredentials({ userId })).map(c => ({\n            kind: CredentialKind.Password,\n            ...c,\n        })))\n    }\n\n    async function loadSso () {\n        credentials.push(...(await api.getSsoCredentials({ userId })).map(c => ({\n            kind: CredentialKind.Sso,\n            ...c,\n        })))\n    }\n\n    async function loadPublicKeys () {\n        credentials.push(...(await api.getPublicKeyCredentials({ userId })).map(c => ({\n            kind: CredentialKind.PublicKey,\n            ...c,\n        })))\n    }\n\n    async function loadCertificates () {\n        credentials.push(...(await api.getCertificateCredentials({ userId })).map(c => ({\n            kind: CredentialKind.Certificate,\n            ...c,\n        })))\n    }\n\n    async function loadOtp () {\n        credentials.push(...(await api.getOtpCredentials({ userId })).map(c => ({\n            kind: CredentialKind.Totp,\n            ...c,\n        })))\n    }\n\n    async function deleteCredential (credential: ExistingCredential) {\n        if (credential.kind === CredentialKind.Certificate) {\n            if (!confirm('Permanently revoke certificate? This cannot be undone.')) {\n                return\n            }\n        }\n\n        credentials = credentials.filter(c => c !== credential)\n\n        if (credential.kind === CredentialKind.Password) {\n            await api.deletePasswordCredential({\n                id: credential.id,\n                userId,\n            })\n        }\n        if (credential.kind === CredentialKind.Sso) {\n            await api.deleteSsoCredential({\n                id: credential.id,\n                userId,\n            })\n        }\n        if (credential.kind === CredentialKind.PublicKey) {\n            await api.deletePublicKeyCredential({\n                id: credential.id,\n                userId,\n            })\n        }\n        if (credential.kind === CredentialKind.Certificate) {\n            await api.revokeCertificateCredential({\n                id: credential.id,\n                userId,\n            })\n        }\n        if (credential.kind === CredentialKind.Totp) {\n            await api.deleteOtpCredential({\n                id: credential.id,\n                userId,\n            })\n        }\n    }\n\n    async function createPassword (password: string) {\n        const credential = await api.createPasswordCredential({\n            userId,\n            newPasswordCredential: {\n                password,\n            },\n        })\n        credentials.push({\n            kind: CredentialKind.Password,\n            ...credential,\n        })\n    }\n\n    async function createOtp (secretKey: number[]) {\n        const credential = await api.createOtpCredential({\n            userId,\n            newOtpCredential: {\n                secretKey,\n            },\n        })\n        credentials.push({\n            kind: CredentialKind.Totp,\n            ...credential,\n        })\n\n        // Automatically set up a 2FA policy when adding an OTP\n        for (const protocol of ['http', 'ssh'] as ('http'|'ssh')[]) {\n            for (const ck of [CredentialKind.Password, CredentialKind.PublicKey]) {\n                const effectiveCreds = getEffectivePossibleCredentials(protocol)\n                if (\n                    !credentialPolicy[protocol]\n                    && credentials.some(x => x.kind === ck)\n                    && effectiveCreds.has(ck)\n                ) {\n                    credentialPolicy = {\n                        ...credentialPolicy ?? {},\n                        [protocol]: [ck, CredentialKind.Totp],\n                    }\n                }\n            }\n        }\n    }\n\n    async function saveSsoCredential (provider: string|null, email: string) {\n        if (editingSsoCredentialInstance) {\n            editingSsoCredentialInstance.provider = provider ?? undefined\n            editingSsoCredentialInstance.email = email\n            await api.updateSsoCredential({\n                userId,\n                id: editingSsoCredentialInstance.id,\n                newSsoCredential: editingSsoCredentialInstance,\n            })\n        } else {\n            const credential = await api.createSsoCredential({\n                userId,\n                newSsoCredential: {\n                    provider:provider ?? undefined,\n                    email,\n                },\n            })\n            credentials.push({\n                kind: CredentialKind.Sso,\n                ...credential,\n            })\n        }\n        editingSsoCredential = false\n        editingSsoCredentialInstance = null\n    }\n\n    async function savePublicKeyCredential (label: string, opensshPublicKey: string) {\n        if (editingPublicKeyCredentialInstance) {\n            editingPublicKeyCredentialInstance.label = label\n            editingPublicKeyCredentialInstance.opensshPublicKey = opensshPublicKey\n            await api.updatePublicKeyCredential({\n                userId,\n                id: editingPublicKeyCredentialInstance.id,\n                newPublicKeyCredential: editingPublicKeyCredentialInstance,\n            })\n        } else {\n            const credential = await api.createPublicKeyCredential({\n                userId,\n                newPublicKeyCredential: {\n                    label,\n                    opensshPublicKey,\n                },\n            })\n            credentials.push({\n                kind: CredentialKind.PublicKey,\n                ...credential,\n            })\n        }\n        editingPublicKeyCredential = false\n        editingPublicKeyCredentialInstance = null\n    }\n\n    async function saveCertificateCredential (label: string, publicKeyPem: string) {\n        const response = await api.issueCertificateCredential({\n            userId,\n            issueCertificateCredentialRequest: {\n                label,\n                publicKeyPem,\n            },\n        })\n\n        credentials.push({\n            kind: CredentialKind.Certificate,\n            ...response.credential,\n        })\n\n        return response\n    }\n</script>\n\n<div class=\"d-flex align-items-center mt-4 mb-2\">\n    <h4 class=\"m-0\">Credentials</h4>\n    <span class=\"ms-auto\"></span>\n    {#if $adminPermissions.usersEdit}\n    <Button size=\"sm\" color=\"link\" on:click={() => creatingPassword = true}>\n        Add password\n    </Button>\n    <Button size=\"sm\" color=\"link\" on:click={() => {\n        editingCertificateCredential = true\n    }}>Issue certificate</Button>\n    <Button\n        id=\"addPublicKeyCredentialButton\"\n        size=\"sm\"\n        color=\"link\"\n        on:click={() => {\n            if (ldapLinked) {\n                return\n            }\n            editingPublicKeyCredentialInstance = null\n            editingPublicKeyCredential = true\n        }}\n        title={ldapLinked ? 'SSH keys are managed by LDAP' : ''}\n    >Add public key</Button>\n    <Tooltip delay=\"250\" target=\"addPublicKeyCredentialButton\" animation>Public key credentials will be loaded from LDAP</Tooltip>\n\n    <Button size=\"sm\" color=\"link\" on:click={() => creatingOtp = true}>Add OTP</Button>\n    <Button size=\"sm\" color=\"link\" on:click={() => {\n        editingSsoCredentialInstance = null\n        editingSsoCredential = true\n    }}>Add SSO</Button>\n    {/if}\n</div>\n\n<Loadable promise={loadPromise}>\n    {#if credentials.length === 0}\n        <EmptyState\n            title=\"No credentials added\"\n            hint=\"Users need credentials to authenticate with Warpgate\"\n        />\n    {/if}\n    <div class=\"list-group list-group-flush mb-3\">\n        {#each credentials as credential (credential.id)}\n        <div class=\"list-group-item credential gap-2\">\n            {#if credential.kind === CredentialKind.Password }\n                <Fa fw icon={faKeyboard} />\n                <span class=\"label me-auto\">Password</span>\n            {/if}\n            {#if credential.kind === 'PublicKey'}\n                <Fa fw icon={faKey} />\n                <div class=\"main me-auto\">\n                    <div class=\"label d-flex align-items-center\">\n                        {credential.label}\n                    </div>\n                    <small class=\"d-block text-muted\">{abbreviatePublicKey(credential.opensshPublicKey)}</small>\n                </div>\n                <CredentialUsedStateBadge credential={credential} />\n            {/if}\n            {#if credential.kind === CredentialKind.Certificate}\n                <Fa fw icon={faCertificate} />\n                <div class=\"main me-auto abbreviate\">\n                    <div class=\"label d-flex align-items-center\">\n                        {credential.label}\n                    </div>\n                    <small class=\"d-block text-muted abbreviate\">SHA-256: <code>{credential.fingerprint}</code></small>\n                </div>\n                <CredentialUsedStateBadge credential={credential} />\n            {/if}\n            {#if credential.kind === 'Totp'}\n                <Fa fw icon={faMobileScreen} />\n                <span class=\"label me-auto\">One-time password</span>\n            {/if}\n            {#if credential.kind === CredentialKind.Sso}\n                <Fa fw icon={faIdBadge} />\n                <span class=\"label\">Single sign-on</span>\n                <span class=\"text-muted me-auto\">\n                    {credential.email}\n                    {#if credential.provider} ({credential.provider}){/if}\n                </span>\n            {/if}\n\n            {#if credential.kind === CredentialKind.PublicKey || credential.kind === CredentialKind.Sso}\n            <Button\n                class=\"px-0\"\n                color=\"link\"\n                disabled={credential.kind === CredentialKind.PublicKey && (ldapLinked || !$adminPermissions.usersEdit)}\n                onclick={e => {\n                    if (credential.kind === CredentialKind.Sso) {\n                        editingSsoCredentialInstance = credential\n                        editingSsoCredential = true\n                    }\n                    if (credential.kind === CredentialKind.PublicKey) {\n                        editingPublicKeyCredentialInstance = credential\n                        editingPublicKeyCredential = true\n                    }\n                    e.preventDefault()\n                }}>\n                Change\n            </Button>\n            {/if}\n            <Button\n                class=\"px-0\"\n                color=\"link\"\n                disabled={credential.kind === CredentialKind.PublicKey && (ldapLinked || !$adminPermissions.usersEdit)}\n                onclick={e => {\n                    deleteCredential(credential)\n                    e.preventDefault()\n                }}>\n                Delete\n            </Button>\n        </div>\n        {/each}\n    </div>\n\n    <h4>Auth policy</h4>\n    <div class=\"list-group list-group-flush mb-3\">\n        {#each policyProtocols as protocol (protocol)}\n            {@const effectiveCredentials = getEffectivePossibleCredentials(protocol.id)}\n            <div class=\"list-group-item\">\n                <div class=\"mb-1\">\n                    <strong>{protocol.name}</strong>\n                </div>\n                {#if effectiveCredentials.size > 0}\n                    <AuthPolicyEditor\n                        bind:value={credentialPolicy}\n                        existingCredentials={credentials}\n                        possibleCredentials={effectiveCredentials}\n                        protocolId={protocol.id}\n                    />\n                {:else}\n                    <span class=\"text-muted\">No authentication methods available for this protocol</span>\n                {/if}\n            </div>\n        {/each}\n    </div>\n</Loadable>\n\n{#if creatingPassword}\n<CreatePasswordModal\n    bind:isOpen={creatingPassword}\n    create={createPassword}\n/>\n{/if}\n\n{#if creatingOtp}\n<CreateOtpModal\n    bind:isOpen={creatingOtp}\n    {username}\n    create={createOtp}\n/>\n{/if}\n\n{#if editingSsoCredential}\n<SsoCredentialModal\n    bind:isOpen={editingSsoCredential}\n    instance={editingSsoCredentialInstance}\n    save={saveSsoCredential}\n/>\n{/if}\n\n{#if editingPublicKeyCredential}\n<PublicKeyCredentialModal\n    bind:isOpen={editingPublicKeyCredential}\n    instance={editingPublicKeyCredentialInstance ?? undefined}\n    save={savePublicKeyCredential}\n/>\n{/if}\n\n{#if editingCertificateCredential}\n<CertificateCredentialModal\n    bind:isOpen={editingCertificateCredential}\n    save={saveCertificateCredential}\n    {username}\n    onClose={() => {\n        editingCertificateCredential = false\n    }}\n/>\n{/if}\n\n<style lang=\"scss\">\n    .credential {\n        display: flex;\n        align-items: center;\n\n        .label:not(:first-child), .main {\n            margin-left: .75rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/Home.svelte",
    "content": "<script lang=\"ts\">\n    import Fa from 'svelte-fa'\n    import { faCircleDot as iconActive } from '@fortawesome/free-regular-svg-icons'\n    import { onDestroy } from 'svelte'\n    import { link } from 'svelte-spa-router'\n    import { api, type SessionSnapshot } from 'admin/lib/api'\n    import { formatDistance } from 'date-fns'\n    import { timer, Observable, switchMap, from, combineLatest, fromEvent, merge } from 'rxjs'\n    import RelativeDate from './RelativeDate.svelte'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'\n    import { Input } from '@sveltestrap/sveltestrap'\n    import { autosave } from 'common/autosave'\n    import GettingStarted from 'common/GettingStarted.svelte'\n    import { serverInfo } from 'gateway/lib/store'\n    import { adminPermissions } from './lib/store'\n    import PermissionGate from './lib/PermissionGate.svelte'\n\n    let [showActiveOnly, showActiveOnly$] = autosave('sessions-list:show-active-only', false)\n    let [showLoggedInOnly, showLoggedInOnly$] = autosave('sessions-list:show-logged-in-only', true)\n\n    let activeSessionCount: number|undefined = $state()\n\n    let socket = new WebSocket(`wss://${location.host}/@warpgate/admin/api/sessions/changes`)\n    let sessionChanges$ = fromEvent(socket, 'message')\n    onDestroy(() => socket.close())\n\n    function loadSessions (opt: LoadOptions): Observable<PaginatedResponse<SessionSnapshot>> {\n        if (!$adminPermissions.sessionsView) {\n            // return empty observable\n            return from(Promise.resolve({ items: [], offset: 0, total: 0 }))\n        }\n        return combineLatest([\n            showActiveOnly$,\n            showLoggedInOnly$,\n            merge(timer(0, 60000), sessionChanges$),\n        ]).pipe(switchMap(([activeOnly, loggedInOnly]) => {\n            api.getSessions({\n                activeOnly: true,\n                limit: 1,\n            }).then(response => {\n                activeSessionCount = response.total\n            })\n            return from(api.getSessions({\n                activeOnly,\n                loggedInOnly,\n                ...opt,\n            }))\n        }))\n    }\n\n    async function _reloadSessions (): Promise<void> {\n        activeSessionCount = (await api.getSessions({ activeOnly: true })).total\n    }\n\n    async function closeAllSesssions () {\n        await api.closeAllSessions()\n    }\n\n    function describeSession (session: SessionSnapshot): string {\n        let user = session.username ?? (session.ended ? '<not logged in>' : '<logging in>')\n        if (!session.target) {\n            return user\n        }\n        let target = session.target.name\n        return `${user} on ${target}`\n    }\n\n    _reloadSessions()\n    const interval = setInterval(_reloadSessions, 1000000)\n    onDestroy(() => clearInterval(interval))\n</script>\n\n{#if $serverInfo?.setupState}\n    <GettingStarted\n        setupState={$serverInfo?.setupState} />\n{/if}\n\n<PermissionGate perm=\"sessionsView\" message=\"You have no permission to view sessions.\">\n    {#if activeSessionCount !== undefined}\n    <div class=\"page-summary-bar\">\n        {#if activeSessionCount }\n            <h1>\n                <span>active sessions:</span> <span class=\"counter\">{activeSessionCount}</span>\n            </h1>\n            <div class=\"ms-auto\">\n                {#if $adminPermissions.sessionsTerminate}\n                <AsyncButton color=\"warning\" click={closeAllSesssions}>\n                    Close all\n                </AsyncButton>\n                {/if}\n            </div>\n        {:else}\n            <h1>no active sessions</h1>\n        {/if}\n    </div>\n    {/if}\n\n    <ItemList load={loadSessions} pageSize={100}>\n        {#snippet header()}\n            <div  class=\"d-flex align-items-center mb-1 w-100\">\n                <div class=\"ms-auto\"></div>\n                <Input class=\"ms-3\" type=\"switch\" label=\"Active only\" bind:checked={$showActiveOnly} />\n                <Input class=\"ms-3\" type=\"switch\" label=\"Logged in only\" bind:checked={$showLoggedInOnly} />\n            </div>\n        {/snippet}\n\n        {#snippet item(session)}\n            <a\n\n                class=\"list-group-item list-group-item-action\"\n                href=\"/sessions/{session.id}\"\n                use:link>\n                <div class=\"main\">\n                    <div class=\"icon\" class:text-success={!session.ended}>\n                        {#if !session.ended}\n                            <Fa icon={iconActive} fw />\n                        {/if}\n                    </div>\n                    <div class=\"protocol text-muted me-2\">{session.protocol}</div>\n                    <strong>\n                        {describeSession(session)}\n                    </strong>\n\n                    <div class=\"meta\">\n                        {#if session.ended }\n                            {formatDistance(new Date(session.started), new Date(session.ended))}\n                        {/if}\n                    </div>\n\n                    <div class=\"meta ms-auto\">\n                        <RelativeDate date={session.started} />\n                    </div>\n                </div>\n            </a>\n        {/snippet}\n    </ItemList>\n</PermissionGate>\n\n<style lang=\"scss\">\n    .list-group-item {\n        .icon {\n            display: flex;\n            align-items: center;\n            margin-right: 5px;\n            width: 20px;\n        }\n\n        .main {\n            display: flex;\n            align-items: center;\n        }\n\n        .protocol {\n            min-width: 3.5rem;\n        }\n\n        .meta {\n            opacity: .75;\n            margin-left: 25px;\n            font-size: .75rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/KubernetesRecording.svelte",
    "content": "<script lang=\"ts\">\n    import Badge from 'common/sveltestrap-s5-ports/Badge.svelte'\n    import type { KubernetesRecordingItem } from './lib/api'\n    import { firstBy } from 'thenby'\n    interface Props {\n        items: KubernetesRecordingItem[]\n    }\n\n    let { items }: Props = $props()\n\n    const sortedItems = $derived(items.slice().sort(firstBy(x => x.timestamp)).reverse())\n\n    function isSuccessStatus(status: number) {\n        return status >= 200 && status < 300\n    }\n</script>\n\n{#each sortedItems as item (item)}\n    <div class=\"item\">\n        <div class=\"d-flex align-items-center gap-2\">\n            {#if item.responseStatus}\n            <Badge color={isSuccessStatus(item.responseStatus) ? 'success' : 'danger'}>\n                {item.responseStatus}\n            </Badge>\n            {/if}\n            {#if item.requestMethod === 'GET'}\n            <Badge color=\"success\">\n                {item.requestMethod}\n            </Badge>\n            {:else}\n            <Badge color=\"warning\">\n                {item.requestMethod}\n            </Badge>\n            {/if}\n            <code>{item.requestPath}</code>\n            <div class=\"text-muted ms-auto\">{item.timestamp.toLocaleString()}</div>\n        </div>\n\n        <div class=\"contents\">\n        {#if item.requestBody}\n            <details>\n                <summary>Request body ({item.requestBody.kind})</summary>\n                <pre>{JSON.stringify(item.requestBody, undefined, 2)}</pre>\n            </details>\n        {/if}\n\n        {#if item.responseBody}\n            <details>\n                <summary>Response body ({item.responseBody.kind})</summary>\n                {#if item.responseBody.kind === 'Table'}\n                    <table class=\"table\">\n                        <thead>\n                            <tr>\n                                {#each item.responseBody.columnDefinitions as colDef (colDef)}\n                                    <th>{colDef.name}</th>\n                                {/each}\n                            </tr>\n                        </thead>\n                        <tbody>\n                            {#each item.responseBody.rows as row (row)}\n                                <tr>\n                                    <!-- eslint-disable-next-line svelte/require-each-key -->\n                                    {#each row.cells as cell}\n                                        <td>{cell}</td>\n                                    {/each}\n                                </tr>\n                            {/each}\n                        </tbody>\n                    </table>\n                {:else if item.responseBody.kind?.endsWith('List') }\n                    {#if item.responseBody.items?.length === 0}\n                    <table class=\"table\">\n                        <tbody>\n                            <tr>\n                                <td>\n                                    <div class=\"text-muted\">No items</div>\n                                </td>\n                            </tr>\n                        </tbody>\n                    </table>\n                    {:else}\n                    <table class=\"table\">\n                        <thead>\n                            <tr>\n                                <th>Name</th>\n                                <th>Namespace</th>\n                                <th></th>\n                            </tr>\n                        </thead>\n                        <tbody>\n                            {#each item.responseBody.items as row (row)}\n                                <tr>\n                                    <td>{row.metadata.name}</td>\n                                    <td>{row.metadata.namespace}</td>\n                                    <td>\n                                        <details>\n                                            <summary>Full entry</summary>\n                                            <pre>{JSON.stringify(row, undefined, 2)}</pre>\n                                        </details>\n                                    </td>\n                                </tr>\n                            {/each}\n                        </tbody>\n                    </table>\n                    {/if}\n                {:else}\n                    <pre>{JSON.stringify(item.responseBody, undefined, 2)}</pre>\n                {/if}\n            </details>\n        {/if}\n        </div>\n        <!-- TODO -->\n    </div>\n{/each}\n\n<style lang=\"scss\">\n    .item {\n        .contents {\n            margin-left: 7rem;\n            margin-bottom: 1rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/Log.svelte",
    "content": "<script lang=\"ts\">\nimport LogViewer from './LogViewer.svelte'\n</script>\n\n<div class=\"page-summary-bar\">\n    <h1>log</h1>\n</div>\n\n<LogViewer filters={{}} />\n"
  },
  {
    "path": "warpgate-web/src/admin/LogViewer.svelte",
    "content": "<script lang=\"ts\">\nimport { api, type LogEntry } from 'admin/lib/api'\nimport { firstBy } from 'thenby'\nimport IntersectionObserver from 'svelte-intersection-observer'\nimport { link } from 'svelte-spa-router'\nimport { onDestroy, onMount } from 'svelte'\nimport { stringifyError } from 'common/errors'\nimport Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n\ninterface Props {\n    filters: {\n        sessionId?: string,\n    } | undefined;\n}\n\nlet { filters }: Props = $props()\n\nlet error: string|null = $state(null)\nlet items: LogEntry[]|undefined\nlet visibleItems: LogEntry[]|undefined = $state()\nlet loading = $state(true)\nlet endReached = $state(false)\nlet loadOlderButton: HTMLButtonElement|undefined = $state()\nlet reloadInterval: any\nlet searchQuery = $state('')\nconst PAGE_SIZE = 1000\n\nfunction addItems (newItems: LogEntry[]) {\n    let existingIds = new Set(items?.map(i => i.id) ?? [])\n    newItems = newItems.filter(i => !existingIds.has(i.id))\n    newItems.sort(firstBy('timestamp', -1))\n    if (!newItems.length) {\n        return\n    }\n    items ??= []\n    if ((items?.[0]?.timestamp ?? 0) > newItems[0]!.timestamp) {\n        items = items.concat(newItems)\n    } else {\n        items = [\n            ...newItems,\n            ...items,\n        ]\n    }\n}\n\nasync function loadNewer () {\n    loading = true\n    try {\n        const newItems = await api.getLogs({\n            getLogsRequest: {\n                ...filters ?? {},\n                after: items?.at(0)?.timestamp,\n                limit: PAGE_SIZE,\n                search: searchQuery,\n            },\n        })\n        addItems(newItems)\n        visibleItems = items\n    } finally {\n        loading = false\n    }\n}\n\nasync function loadOlder (searchMode = false) {\n    loading = true\n    try {\n        const newItems = await api.getLogs({\n            getLogsRequest: {\n                ...filters ?? {},\n                before: searchMode ? undefined : items?.at(-1)?.timestamp,\n                limit: PAGE_SIZE,\n                search: searchQuery,\n            },\n        })\n        if (searchMode) {\n            endReached = false\n            items = []\n        }\n        addItems(newItems)\n        visibleItems = items\n        if (!newItems.length) {\n            endReached = true\n        }\n    } finally {\n        loading = false\n    }\n}\n\nfunction search () {\n    loadOlder(true)\n}\n\nfunction stringifyDate (date: Date) {\n    return date.toLocaleString()\n}\n\nloadOlder().catch(async e => {\n    error = await stringifyError(e)\n})\n\nonMount(() => {\n    reloadInterval = setInterval(() => {\n        if (!loading) {\n            loadNewer()\n        }\n    }, 1000)\n})\n\nonDestroy(() => {\n    clearInterval(reloadInterval)\n})\n\n</script>\n\n{#if error}\n    <Alert color=\"danger\">{error}</Alert>\n{/if}\n\n<input\n    placeholder=\"Search...\"\n    type=\"text\"\n    class=\"form-control form-control-sm mb-2\"\n    bind:value={searchQuery}\n    onkeyup={() => search()} />\n\n{#if visibleItems}\n    <div class=\"table-wrapper\">\n        <table class=\"w-100\">\n            <tbody>\n                {#each visibleItems as item (item.id)}\n                    <tr>\n                        <td class=\"timestamp pe-4\">\n                            {stringifyDate(item.timestamp)}\n                        </td>\n                        {#if !filters?.sessionId}\n                            <td class=\"username pe-4\">\n                                {#if item.username}\n                                    {item.username}\n                                {/if}\n                            </td>\n                            <td class=\"session pe-4\">\n                                {#if item.sessionId}\n                                    <a href=\"/sessions/{item.sessionId}\" use:link>\n                                        {item.sessionId}\n                                    </a>\n                                {/if}\n                            </td>\n                        {/if}\n                        <td class=\"content\">\n                            <span class=\"text\">\n                                {item.text}\n                            </span>\n\n                            {#each Object.entries(item.values ?? {}) as pair (pair[0])}\n                                <span class=\"key\">{pair[0]}:</span>\n                                <span class=\"value\">{pair[1]}</span>\n                            {/each}\n                        </td>\n                    </tr>\n                {/each}\n                {#if !endReached}\n                    {#if !loading}\n                        <tr>\n                            <td colspan=\"3\">\n                                <IntersectionObserver element={loadOlderButton} on:observe={event => {\n                                    if (!loading && event.detail.isIntersecting) {\n                                        loadOlder()\n                                    }\n                                }}>\n                                    <button\n                                        bind:this={loadOlderButton}\n                                        class=\"btn btn-light\"\n                                        onclick={() => loadOlder()}\n                                        disabled={loading}\n                                    >\n                                        Load older\n                                    </button>\n                                </IntersectionObserver>\n                            </td>\n                        </tr>\n                    {/if}\n                {:else}\n                    <tr>\n                        <td></td>\n                        {#if !filters?.sessionId}\n                            <td></td>\n                            <td></td>\n                        {/if}\n                        <td class=\"text\">End of the log</td>\n                    </tr>\n                {/if}\n            </tbody>\n        </table>\n    </div>\n{/if}\n\n<style lang=\"scss\">\n    @import \"../theme/vars.light\";\n\n    .table-wrapper {\n        max-width: 100%;\n        overflow-x: auto;\n    }\n\n    tr {\n        td {\n            font-family: $font-family-monospace;\n            font-size: 0.75rem;\n            white-space: nowrap;\n        }\n\n        .timestamp {\n            opacity: .75;\n        }\n\n        td:not(:last-child) {\n            padding-right: 1em;\n        }\n\n        .content {\n            display: flex;\n\n            .text {\n                font-weight: bold;\n                margin-right: 0.6em;\n            }\n\n            .key {\n                margin-left: 0.5em;\n                margin-right: 0.3em;\n                opacity: .5;\n                font-style: italic;\n            }\n\n            .value {\n                font-style: italic;\n            }\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/PublicKeyCredentialModal.svelte",
    "content": "<script lang=\"ts\">\n    import {\n        Button,\n        Form,\n        FormGroup,\n        Input,\n        Modal,\n        ModalBody,\n        ModalFooter,\n    } from '@sveltestrap/sveltestrap'\n\n    import { type ExistingPublicKeyCredential } from './lib/api'\n\n    interface Props {\n        isOpen: boolean\n        instance?: ExistingPublicKeyCredential\n        save: (label: string, opensshPublicKey: string) => void\n    }\n\n    let {\n        isOpen = $bindable(true),\n        instance,\n        save,\n    }: Props = $props()\n\n    let field: HTMLInputElement|undefined = $state()\n    let label: string = $state('')\n    let opensshPublicKey: string = $state('')\n    let validated = $state(false)\n\n    const PK_REGEX = /^ssh-([\\w-]+) [A-Za-z0-9+/=]+( (?<comment>[^ ]+))?$/\n\n    function _save () {\n        if (!opensshPublicKey || !label) {\n            return\n        }\n        if (opensshPublicKey.includes(' ')) {\n            const parts = opensshPublicKey.split(' ').filter(x => x)\n            opensshPublicKey = `${parts[0]} ${parts[1]}`\n        }\n        isOpen = false\n        save(label, opensshPublicKey)\n    }\n\n    function _cancel () {\n        isOpen = false\n    }\n\n    $effect(() => field?.addEventListener('paste', e => {\n        const clipboardData = e.clipboardData\n        if (clipboardData) {\n            const newValue = clipboardData.getData('text')\n            onPublicKeyPaste(newValue)\n        }\n    }))\n\n    function onPublicKeyPaste (newValue: string) {\n        const match = PK_REGEX.exec(newValue)\n        if (!label && match) {\n            label = match.groups?.comment || ''\n        }\n    }\n</script>\n\n<Modal toggle={_cancel} isOpen={isOpen} on:open={() => {\n    if (instance) {\n        label = instance.label\n        opensshPublicKey = instance.opensshPublicKey\n    }\n    field?.focus()\n}}>\n    <Form {validated} on:submit={e => {\n        _save()\n        e.preventDefault()\n    }}>\n        <ModalBody>\n            <FormGroup floating label=\"Label\">\n                <Input\n                    bind:inner={field}\n                    type=\"text\"\n                    required\n                    bind:value={label} />\n            </FormGroup>\n            <FormGroup floating label=\"Public key in OpenSSH format\" spacing=\"0\">\n                <Input\n                    style=\"font-family: monospace; height: 15rem\"\n                    bind:inner={field}\n                    type=\"textarea\"\n                    required\n                    placeholder=\"ssh-XXX YYYYYY\"\n                    bind:value={opensshPublicKey} />\n            </FormGroup>\n        </ModalBody>\n        <ModalFooter>\n            <Button\n                type=\"submit\"\n                color=\"primary\"\n                class=\"modal-button\"\n                on:click={() => validated = true}\n            >Save</Button>\n\n            <Button\n                class=\"modal-button\"\n                color=\"danger\"\n                on:click={_cancel}\n            >Cancel</Button>\n        </ModalFooter>\n    </Form>\n</Modal>\n"
  },
  {
    "path": "warpgate-web/src/admin/Recording.svelte",
    "content": "<script lang=\"ts\">\n    import { api, RecordingKind, type Recording } from 'admin/lib/api'\n    import TerminalRecordingPlayer from 'admin/player/TerminalRecordingPlayer.svelte'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import DelayedSpinner from 'common/DelayedSpinner.svelte'\n    import { stringifyError } from 'common/errors'\n    import KubernetesRecording from './KubernetesRecording.svelte'\n    import Loadable from 'common/Loadable.svelte'\n\n    interface Props {\n        params: { id: string }\n    }\n\n    let { params = { id: '' } }: Props = $props()\n\n    let error: string|null = $state(null)\n    import { adminPermissions } from './lib/store'\n    let recording: Recording|null = $state(null)\n\n    async function load () {\n        if (!$adminPermissions.recordingsView) {\n            error = 'You do not have permission to view recordings.'\n            return\n        }\n        recording = await api.getRecording(params)\n    }\n\n    function getTCPDumpURL () {\n        return `/@warpgate/admin/api/recordings/${recording?.id}/tcpdump`\n    }\n\n    load().catch(async e => {\n        error = await stringifyError(e)\n    })\n</script>\n\n<div class=\"page-summary-bar\">\n    <h1>session recording</h1>\n</div>\n\n{#if !recording && !error}\n    <DelayedSpinner />\n{/if}\n\n{#if error}\n<Alert color=\"danger\">{error}</Alert>\n{/if}\n\n{#if recording?.kind === RecordingKind.Traffic}\n    <a href={getTCPDumpURL()}>Download tcpdump file</a>\n{/if}\n{#if recording?.kind === RecordingKind.Terminal}\n    <TerminalRecordingPlayer recording={recording} />\n{/if}\n{#if recording?.kind === RecordingKind.Kubernetes}\n    <Loadable promise={api.getKubernetesRecording({ id: recording.id })}>\n        {#snippet children(items)}\n            <KubernetesRecording items={items} />\n        {/snippet}\n    </Loadable>\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/admin/RelativeDate.svelte",
    "content": "<script lang=\"ts\">\nimport { timeAgo } from 'admin/lib/time'\ninterface Props {\n    date: Date;\n}\n\nlet { date }: Props = $props()\n</script>\n\n<span title={date.toLocaleString()}>{timeAgo(date)}</span>\n"
  },
  {
    "path": "warpgate-web/src/admin/Session.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type SessionSnapshot, type Recording, type TargetSSHOptions, type TargetHTTPOptions, type TargetMySqlOptions, type TargetPostgresOptions, type TargetKubernetesOptions } from 'admin/lib/api'\n    import { timeAgo } from 'admin/lib/time'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import DelayedSpinner from 'common/DelayedSpinner.svelte'\n    import { formatDistance, formatDistanceToNow } from 'date-fns'\n    import { onDestroy } from 'svelte'\n    import { link } from 'svelte-spa-router'\n    import LogViewer from './LogViewer.svelte'\n    import RelativeDate from './RelativeDate.svelte'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Fa from 'svelte-fa'\n    import { faUser } from '@fortawesome/free-regular-svg-icons'\n    import Badge from 'common/sveltestrap-s5-ports/Badge.svelte'\n    import { faArrowRight } from '@fortawesome/free-solid-svg-icons'\n    import Tooltip from 'common/sveltestrap-s5-ports/Tooltip.svelte'\n    import { PROTOCOL_PROPERTIES } from 'common/protocols'\n    import { recordingMetadataToFieldSet, recordingTypeLabel } from 'common/recordings'\n\n    interface Props {\n        params: { id: string }\n    }\n\n    let { params = { id: '' } }: Props = $props()\n\n    let error: string|null = $state(null)\n    let session: SessionSnapshot|null = $state(null)\n    let recordings: Recording[]|null = $state(null)\n\n    async function load () {\n        session = await api.getSession(params)\n        recordings = await api.getSessionRecordings(params)\n    }\n\n    async function close () {\n        api.closeSession(session!)\n    }\n\n    function getTargetDescription () {\n        if (session?.target) {\n            let address = '<unknown>'\n            if (session.target.options.kind === 'Ssh') {\n                const options = session.target.options as TargetSSHOptions\n                address = `${options.host}:${options?.port}`\n            }\n            if (session.target.options.kind === 'MySql') {\n                const options = session.target.options as TargetMySqlOptions\n                address = `${options.host}:${options?.port}`\n            }\n            if (session.target.options.kind === 'Postgres') {\n                const options = session.target.options as TargetPostgresOptions\n                address = `${options.host}:${options?.port}`\n            }\n            if (session.target.options.kind === 'Http') {\n                const options = session.target.options as unknown as TargetHTTPOptions\n                address = options.url\n            }\n            if (session.target.options.kind === 'Kubernetes') {\n                const options = session.target.options as unknown as TargetKubernetesOptions\n                address = options.clusterUrl\n            }\n            return `${session.target.name} (${address})`\n        } else {\n            return 'Not selected yet'\n        }\n    }\n\n    load().catch(async e => {\n        error = await stringifyError(e)\n    })\n\n    const interval = setInterval(load, 1000)\n    onDestroy(() => clearInterval(interval))\n\n</script>\n\n{#if !session && !error}\n    <DelayedSpinner />\n{/if}\n\n{#if error}\n    <Alert color=\"danger\">{error}</Alert>\n{/if}\n\n{#if session}\n    <div class=\"page-summary-bar\">\n        <div>\n            <h1>session</h1>\n            <div class=\"d-flex align-items-center mt-1\">\n                <Tooltip delay=\"250\" target=\"usernameBadge\" animation>Authenticated user</Tooltip>\n                <Tooltip delay=\"250\" target=\"targetBadge\" animation>Selected target</Tooltip>\n\n                <Badge id=\"usernameBadge\" color=\"success\" class=\"me-2 d-flex align-items-center\">\n                    {#if session.username}\n                        <Fa icon={faUser} class=\"me-2\" />\n                        {session.username}\n                    {:else}\n                        Logging in\n                    {/if}\n                </Badge>\n                {#if session.target}\n                    <Badge id=\"targetBadge\" color=\"info\" class=\"me-2 d-flex align-items-center\">\n                        <Fa icon={faArrowRight} class=\"me-2\" />\n                        {getTargetDescription()}\n                    </Badge>\n                {/if}\n                <span class=\"text-muted\">\n                    {#if session.ended}\n                        {formatDistance(new Date(session.started), new Date(session.ended))} long, <RelativeDate date={session.started} />\n                    {:else}\n                        {formatDistanceToNow(new Date(session.started))}\n                    {/if}\n                </span>\n            </div>\n        </div>\n        {#if !session.ended && PROTOCOL_PROPERTIES[session.protocol]?.sessionsCanBeClosed}\n            <div class=\"ms-auto\">\n                <AsyncButton color=\"warning\" click={close}>\n                    Close now\n                </AsyncButton>\n            </div>\n        {/if}\n    </div>\n\n    {#if recordings?.length }\n        <h3 class=\"mt-4\">Recordings</h3>\n        <div class=\"list-group list-group-flush\">\n            {#each recordings as recording (recording.id)}\n                {@const metadata = JSON.parse(recording.metadata)}\n                <a\n                    class=\"list-group-item list-group-item-action\"\n                    href=\"/recordings/{recording.id}\"\n                    use:link>\n                    <div class=\"main gap-1\">\n                        <strong>\n                            {recordingTypeLabel(recording)}\n                            {#if !metadata}\n                                : {recording.name}\n                            {/if}\n                        </strong>\n                        {#if metadata}\n                            {#each recordingMetadataToFieldSet(metadata) as item (item[0])}\n                                <div>\n                                    <span class=\"text-muted\">{item[0]}:</span> {item[1]}\n                                </div>\n                            {/each}\n                        {/if}\n                        <small class=\"meta ms-auto\">\n                            {timeAgo(recording.started)}\n                        </small>\n                    </div>\n                </a>\n            {/each}\n        </div>\n    {/if}\n\n    <h3 class=\"mt-4\">Log</h3>\n    <LogViewer filters={{\n        sessionId: session.id,\n    }} />\n\n{/if}\n\n<style lang=\"scss\">\n.list-group-item {\n    .main {\n        display: flex;\n        align-items: center;\n\n        > * {\n            margin-right: 20px;\n        }\n    }\n\n    .meta {\n        opacity: .75;\n    }\n}\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/SsoCredentialModal.svelte",
    "content": "<script lang=\"ts\">\n    import {\n        Button,\n        Form,\n        FormGroup,\n        Input,\n        Modal,\n        ModalBody,\n        ModalFooter,\n    } from '@sveltestrap/sveltestrap'\n\n    import { type ExistingSsoCredential } from './lib/api'\n    import { api } from 'gateway/lib/api'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Loadable from 'common/Loadable.svelte'\n\n    interface Props {\n        isOpen: boolean\n        instance: ExistingSsoCredential|null\n        save: (provider: string|null, email: string) => void\n    }\n\n    let {\n        isOpen = $bindable(true),\n        instance,\n        save,\n    }: Props = $props()\n\n    let provider: string|null = $state(null)\n    let email: string = $state('')\n    let validated = $state(false)\n\n    function _save () {\n        if (!email) {\n            return\n        }\n        isOpen = false\n        save(provider, email)\n    }\n\n    function _cancel () {\n        isOpen = false\n    }\n</script>\n\n<Modal toggle={_cancel} isOpen={isOpen} on:open={() => {\n    if (instance) {\n        provider = instance.provider ?? null\n        email = instance.email\n    }\n}}>\n    <Form {validated} on:submit={e => {\n        _save()\n        e.preventDefault()\n    }}>\n        <ModalBody>\n            <FormGroup floating label=\"E-mail\">\n                <Input\n                    type=\"email\"\n                    required\n                    bind:value={email} />\n            </FormGroup>\n\n            <Loadable promise={api.getSsoProviders()}>\n                {#snippet children(providers)}\n                    {#if !providers.length}\n                    <Alert color=\"warning\">\n                        You don't have any SSO providers configured. Add them to your config file first.\n                    </Alert>\n                    {/if}\n                    <FormGroup floating label=\"SSO provider\" spacing=\"0\">\n                        <Input\n                            bind:value={provider}\n                            type=\"select\"\n                        >\n                            <option value={null} selected>Any</option>\n                            {#each providers as provider (provider.name)}\n                            <option value={provider.name}>{provider.label ?? provider.name}</option>\n                            {/each}\n                        </Input>\n                    </FormGroup>\n                {/snippet}\n            </Loadable>\n        </ModalBody>\n        <ModalFooter>\n            <Button\n                type=\"submit\"\n                color=\"primary\"\n                class=\"modal-button\"\n                on:click={() => validated = true}\n            >Save</Button>\n\n            <Button\n                class=\"modal-button\"\n                color=\"danger\"\n                on:click={_cancel}\n            >Cancel</Button>\n        </ModalFooter>\n    </Form>\n</Modal>\n"
  },
  {
    "path": "warpgate-web/src/admin/TlsConfiguration.svelte",
    "content": "<script lang=\"ts\">\n    import { type Tls, TlsMode } from 'admin/lib/api'\n    import { FormGroup, Input } from '@sveltestrap/sveltestrap'\n\n    interface Props {\n        value: Tls\n        class?: string\n    }\n\n    // eslint-disable-next-line svelte/no-unused-props\n    let {\n        value = $bindable(),\n        'class': className = '',\n    }: Props = $props()\n</script>\n\n<div class=\"row align-items-center {className}\">\n    <div class=\"col\">\n        <FormGroup floating label=\"TLS mode\">\n            <select bind:value={value.mode} class=\"form-control\">\n                <option value={TlsMode.Required}>Required</option>\n                <option value={TlsMode.Preferred}>Preferred</option>\n                <option value={TlsMode.Disabled}>Disabled</option>\n            </select>\n        </FormGroup>\n    </div>\n    {#if value.mode !== TlsMode.Disabled}\n        <div class=\"col mb-3\">\n            <Input class=\"ms-3\" type=\"switch\" label=\"Verify certificate\" bind:checked={value.verify} />\n        </div>\n    {/if}\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/AccessRole.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type Role, type Target, type User } from 'admin/lib/api'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import { link, replace } from 'svelte-spa-router'\n    import { FormGroup, Input } from '@sveltestrap/sveltestrap'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Loadable from 'common/Loadable.svelte'\n    import ItemList, { type PaginatedResponse } from 'common/ItemList.svelte'\n    import * as rx from 'rxjs'\n    import { adminPermissions } from '../lib/store'\n\n    interface Props {\n        params: { id: string };\n    }\n\n    let { params }: Props = $props()\n\n    let error: string|null = $state(null)\n    let role: Role | undefined = $state()\n    const initPromise = init()\n\n    async function init () {\n        role = await api.getRole({ id: params.id })\n    }\n\n    function loadUsers (): rx.Observable<PaginatedResponse<User>> {\n        return rx.from(api.getRoleUsers({\n            id: params.id,\n        })).pipe(\n            rx.map(targets => ({\n                items: targets,\n                offset: 0,\n                total: targets.length,\n            })),\n        )\n    }\n\n    function loadTargets (): rx.Observable<PaginatedResponse<Target>> {\n        return rx.from(api.getRoleTargets({\n            id: params.id,\n        })).pipe(\n            rx.map(targets => ({\n                items: targets,\n                offset: 0,\n                total: targets.length,\n            })),\n        )\n    }\n\n    async function update () {\n        try {\n            role = await api.updateRole({\n                id: params.id,\n                roleDataRequest: role!,\n            })\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    }\n\n    async function remove () {\n        if (confirm(`Delete role ${role!.name}?`)) {\n            await api.deleteRole(role!)\n            replace('/config/access-roles')\n        }\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <Loadable promise={initPromise}>\n        <div class=\"page-summary-bar\">\n            <div>\n                <h1>{role!.name}</h1>\n                <div class=\"text-muted\">role</div>\n            </div>\n        </div>\n\n        <FormGroup floating label=\"Name\">\n            <Input bind:value={role!.name} />\n        </FormGroup>\n\n        <FormGroup floating label=\"Description\">\n            <Input bind:value={role!.description} />\n        </FormGroup>\n    </Loadable>\n\n    {#if error}\n        <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    <div class=\"d-flex\">\n        <AsyncButton\n        color=\"primary\"\n            disabled={!$adminPermissions.accessRolesEdit}\n            class=\"ms-auto\"\n            click={update}\n        >Update</AsyncButton>\n\n        <AsyncButton\n            class=\"ms-2\"\n            disabled={!$adminPermissions.accessRolesDelete}\n            color=\"danger\"\n            click={remove}\n        >Remove</AsyncButton>\n    </div>\n\n\n    <h4 class=\"mt-4\">Assigned users</h4>\n\n    <ItemList load={loadUsers}>\n        {#snippet item(user)}\n            <a\n                class=\"list-group-item list-group-item-action\"\n                href=\"/config/users/{user.id}\"\n                use:link>\n                <div>\n                    <strong class=\"me-auto\">\n                        {user.username}\n                    </strong>\n                    {#if user.description}\n                        <small class=\"d-block text-muted\">{user.description}</small>\n                    {/if}\n                </div>\n            </a>\n        {/snippet}\n        {#snippet empty()}\n            <Alert color=\"info\">This role has no users assigned to it</Alert>\n        {/snippet}\n    </ItemList>\n\n    <h4 class=\"mt-4\">Assigned targets</h4>\n\n    <ItemList load={loadTargets}>\n        {#snippet item(target)}\n            <a\n                class=\"list-group-item list-group-item-action\"\n                href=\"/config/targets/{target.id}\"\n                use:link>\n                <div class=\"me-auto\">\n                    <strong>\n                        {target.name}\n                    </strong>\n                    {#if target.description}\n                        <small class=\"d-block text-muted\">{target.description}</small>\n                    {/if}\n                </div>\n            </a>\n        {/snippet}\n        {#snippet empty()}\n            <Alert color=\"info\">This role has no targets assigned to it</Alert>\n        {/snippet}\n    </ItemList>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/AccessRoles.svelte",
    "content": "<script lang=\"ts\">\n    import { Observable, from, map } from 'rxjs'\n    import { type Role, api } from 'admin/lib/api'\n    import ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'\n    import { link } from 'svelte-spa-router'\n    import { compare as naturalCompareFactory } from 'natural-orderby'\n    import { adminPermissions } from '../lib/store'\n\n    function getRoles(options: LoadOptions): Observable<PaginatedResponse<Role>> {\n        return from(\n            api.getRoles({\n                search: options.search,\n            })\n        ).pipe(\n            map(roles => {\n                const sorted = roles.sort((a, b) =>\n                    naturalCompareFactory()(\n                        a.name.toLowerCase(),\n                        b.name.toLowerCase()\n                    )\n                )\n\n                return {\n                    items: sorted,\n                    offset: 0,\n                    total: sorted.length,\n                }\n            })\n        )\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <div class=\"page-summary-bar\">\n        <h1>roles</h1>\n        <a\n            class=\"btn btn-primary ms-auto\"\n            href=\"/config/access-roles/create\"\n            class:disabled={!$adminPermissions.accessRolesCreate}\n            use:link>\n            Add a role\n        </a>\n    </div>\n\n    <ItemList load={getRoles} showSearch={true}>\n        {#snippet item(role)}\n            <a\n                class=\"list-group-item list-group-item-action\"\n                href=\"/config/access-roles/{role.id}\"\n                use:link>\n                <div>\n                    <strong class=\"me-auto\">\n                        {role.name}\n                    </strong>\n                    {#if role.description}\n                        <small class=\"d-block text-muted\">{role.description}</small>\n                    {/if}\n                </div>\n            </a>\n        {/snippet}\n    </ItemList>\n</div>\n\n<style lang=\"scss\">\n    .list-group-item {\n        display: flex;\n        align-items: center;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/AdminRole.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type AdminRole } from 'admin/lib/api'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import ItemList, { type PaginatedResponse } from 'common/ItemList.svelte'\n    import { link, replace } from 'svelte-spa-router'\n    import { FormGroup, Input } from '@sveltestrap/sveltestrap'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Loadable from 'common/Loadable.svelte'\n    import * as rx from 'rxjs'\n    import Tooltip from 'common/sveltestrap-s5-ports/Tooltip.svelte'\n    import { adminPermissions, ADMIN_PERMISSIONS, type AdminPermissionDef } from '../lib/store'\n\n    interface Props {\n        params: { id: string };\n    }\n\n    let { params }: Props = $props()\n\n    let error: string|null = $state(null)\n    let role: AdminRole | undefined = $state()\n    const initPromise = init()\n\n    let disabled = $state(false)\n\n    async function init () {\n        role = await api.getAdminRole({ id: params.id })\n        disabled = role.name === 'warpgate:admin' || !$adminPermissions.adminRolesManage\n    }\n\n    function loadUsers (): rx.Observable<PaginatedResponse<any>> {\n        return rx.from(api.getAdminRoleUsers({ id: params.id })).pipe(\n            rx.map(users => ({\n                items: users,\n                offset: 0,\n                total: users.length,\n            })),\n        )\n    }\n\n    // build grouped permission list by category for rendering\n    interface PermGroup { category: string; perms: AdminPermissionDef[] }\n    const permGroups: PermGroup[] = []\n\n    const map: Record<string, AdminPermissionDef[]> = {}\n    for (const p of ADMIN_PERMISSIONS) {\n        const cat = p.category ?? 'Other'\n        map[cat] ??= []\n        map[cat]!.push(p)\n    }\n    for (const [cat, perms] of Object.entries(map)) {\n        permGroups.push({ category: cat, perms })\n    }\n\n    // recursively enables a permission and all of its parent dependencies\n    function enablePermissionDependenciesRecursive(obj: any, key: string) {\n        obj[key] = true\n        const def = ADMIN_PERMISSIONS.find(p => p.key === key)\n        if (def?.deps) {\n            for (const parent of def.deps) {\n                enablePermissionDependenciesRecursive(obj, parent)\n            }\n        }\n    }\n\n    // recursively disables a permission and any children that depend on it\n    function disablePermissionDependantsRecursive(obj: any, key: string) {\n        obj[key] = false\n        // find all permissions that list this key as a dependency\n        for (const p of ADMIN_PERMISSIONS) {\n            if (p.deps?.includes(key)) {\n                disablePermissionDependantsRecursive(obj, p.key)\n            }\n        }\n    }\n\n    // fallback normalization used on save to guarantee consistency\n    function normalizePermissions(obj: any) {\n        // build dependency map from definitions (child -> parents)\n        const deps: Record<string,string[]> = {}\n        for (const p of ADMIN_PERMISSIONS) {\n            if (p.deps) {\n                deps[p.key] = p.deps\n            }\n        }\n        let changed = true\n        while (changed) {\n            changed = false\n            for (const [child, parents] of Object.entries(deps)) {\n                // if child is granted, ensure all parents granted\n                if (obj[child]) {\n                    for (const parent of parents) {\n                        if (!obj[parent]) { obj[parent] = true; changed = true }\n                    }\n                }\n                // if any parent is revoked, child must be revoked as well\n                if (obj[child] && parents.some(p => !obj[p])) {\n                    obj[child] = false\n                    changed = true\n                }\n            }\n        }\n    }\n\n    async function update () {\n        try {\n            normalizePermissions(role!)\n            role = await api.updateAdminRole({ id: params.id, adminRoleDataRequest: role! })\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    }\n\n    async function remove () {\n        if (confirm(`Delete admin role ${role!.name}?`)) {\n            await api.deleteAdminRole(role!)\n            replace('/config/admin-roles')\n        }\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <Loadable promise={initPromise}>\n        <div class=\"page-summary-bar\">\n            <div>\n                <h1>{role!.name}</h1>\n                <div class=\"text-muted\">admin role</div>\n            </div>\n        </div>\n\n        <FormGroup floating label=\"Name\">\n            <Input\n                bind:value={role!.name}\n                disabled={disabled}\n            />\n        </FormGroup>\n\n        <FormGroup floating label=\"Description\">\n            <Input\n                bind:value={role!.description}\n                disabled={disabled}\n            />\n        </FormGroup>\n\n        <h4 class=\"mt-4\">Permissions</h4>\n        <div class=\"row g-3\">\n        <!-- eslint-disable-next-line svelte/require-each-key -->\n        {#each permGroups as { category, perms }}\n        <div class=\"col-6\">\n            <h5 class=\"mt-3\">{category}</h5>\n                {#each perms as { key, label } (key)}\n                    <label class=\"form-check\">\n                        <input\n                            class=\"form-check-input\"\n                            type=\"checkbox\"\n                            bind:checked={(role as any)[key]}\n                            disabled={disabled}\n                            onchange={e => {\n                                const checked = (e.target as HTMLInputElement).checked\n                                if (checked) {\n                                    enablePermissionDependenciesRecursive(role!, key)\n                                } else {\n                                    disablePermissionDependantsRecursive(role!, key)\n                                }\n                            }}\n                        />\n                        <span class=\"form-check-label\">\n                            {label}\n                            {#if ADMIN_PERMISSIONS.find(p=>p.key===key)?.dangerous}\n                                <span id=\"warn-{key}\" class=\"text-warning ms-1\">⚠️</span>\n                                <Tooltip target=\"warn-{key}\" animation delay=\"250\">\n                                    Grants the ability to manage admin roles; use with care.\n                                </Tooltip>\n                            {/if}\n                        </span>\n                    </label>\n                    {/each}\n                </div>\n            {/each}\n        </div>\n        {#if error}\n            <Alert color=\"danger\">{error}</Alert>\n        {/if}\n\n        <div class=\"d-flex mt-3\">\n            <AsyncButton\n                color=\"primary\"\n                disabled={disabled}\n                class=\"ms-auto\"\n                click={update}\n            >Update</AsyncButton>\n\n            <AsyncButton\n                class=\"ms-2\"\n                disabled={disabled}\n                color=\"danger\"\n                click={remove}\n            >Remove</AsyncButton>\n        </div>\n\n        <h4 class=\"mt-4\">Assigned users</h4>\n        <ItemList load={loadUsers}>\n            {#snippet item(user)}\n                <a\n                    class=\"list-group-item list-group-item-action\"\n                    href=\"/config/users/{user.id}\"\n                    use:link>\n                    <div>\n                        <strong class=\"me-auto\">\n                            {user.username}\n                        </strong>\n                        {#if user.description}\n                            <small class=\"d-block text-muted\">{user.description}</small>\n                        {/if}\n                    </div>\n                </a>\n            {/snippet}\n            {#snippet empty()}\n                <Alert color=\"info\">This admin role has no users assigned to it</Alert>\n            {/snippet}\n        </ItemList>\n    </Loadable>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/AdminRolePermissionsBadge.svelte",
    "content": "<script lang=\"ts\">\n    import type { AdminRole } from 'admin/lib/api'\n    import { ADMIN_PERMISSIONS } from '../lib/store'\n    import Tooltip from 'common/sveltestrap-s5-ports/Tooltip.svelte'\n\n    export let role: AdminRole\n\n    // unique id for tooltip target\n    const id = `role-${role.id}`\n\n    function permissionCount(role: AdminRole): number {\n        return ADMIN_PERMISSIONS.reduce(\n            (n, p) => n + (role[p.key] ? 1 : 0),\n            0,\n        )\n    }\n\n    function permissionLists(role: AdminRole): [string, string][] {\n        const categories = [...new Set(\n            ADMIN_PERMISSIONS.filter(p => role[p.key]).map(p => p.category),\n        )]\n        return categories\n            .map(cat => {\n                const perms = ADMIN_PERMISSIONS\n                    .filter(p => p.category === cat && role[p.key])\n                    .map(p => p.label)\n                if (!perms.length) {\n                    return null\n                }\n                return [cat, perms.join(', ')] as [string, string]\n            })\n            .filter((x): x is [string, string] => !!x)\n    }\n</script>\n\n<span class=\"badge bg-secondary\" id={id}>\n    {permissionCount(role)} {permissionCount(role) === 1 ? 'permission' : 'permissions'}\n</span>\n<Tooltip target={id} delay=\"250\">\n    <div class=\"text-start\">\n        {#each permissionLists(role) as [category, perms] ([category, perms])}\n            <div>{category}: <span class=\"text-muted\">{perms}</span></div>\n        {/each}\n    </div>\n</Tooltip>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/AdminRoles.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type AdminRole } from 'admin/lib/api'\n    import { link } from 'svelte-spa-router'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import ItemList, { type PaginatedResponse } from 'common/ItemList.svelte'\n    import * as rx from 'rxjs'\n    import { adminPermissions } from '../lib/store'\n    import PermissionGate from 'admin/lib/PermissionGate.svelte'\n    import AdminRolePermissionsBadge from './AdminRolePermissionsBadge.svelte'\n\n    function loadRoles (): rx.Observable<PaginatedResponse<AdminRole>> {\n        if (!$adminPermissions.adminRolesManage) {\n            return rx.from([])\n        }\n        return rx.from(api.getAdminRoles()).pipe(\n            rx.map(targets => ({\n                items: targets,\n                offset: 0,\n                total: targets.length,\n            })),\n        )\n    }\n\n</script>\n\n<div class=\"container-max-md\">\n    <PermissionGate perm=\"adminRolesManage\" message=\"You have no permission to manage admin roles.\">\n        <div class=\"page-summary-bar\">\n            <div>\n                <h1>admin roles</h1>\n                <div class=\"text-muted\">permissions for administrators</div>\n            </div>\n            <a class=\"btn btn-primary ms-auto\" href=\"/config/admin-roles/create\" use:link>Create</a>\n        </div>\n\n        <ItemList load={loadRoles} showSearch={true}>\n        {#snippet item(role)}\n            <a\n                class=\"list-group-item list-group-item-action d-flex align-items-center\"\n                href=\"/config/admin-roles/{role.id}\"\n                use:link>\n                <div>\n                    <strong class=\"me-auto\">\n                        {role.name}\n                    </strong>\n                    {#if role.description}\n                        <small class=\"d-block text-muted\">{role.description}</small>\n                    {/if}\n                </div>\n                <span class=\"ms-auto\">\n                    <AdminRolePermissionsBadge {role} />\n                </span>\n            </a>\n        {/snippet}\n        {#snippet empty()}\n            <Alert color=\"info\">No admin roles defined</Alert>\n        {/snippet}\n        </ItemList>\n    </PermissionGate>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/Config.svelte",
    "content": "<script lang=\"ts\">\n    import NavListItem from 'common/NavListItem.svelte'\n    import { wrap } from 'svelte-spa-router/wrap'\n    import Router from 'svelte-spa-router'\n\n    const routes = {\n        '/targets/create/:kind': wrap({\n            asyncComponent: () => import('./targets/CreateTarget.svelte') as any,\n        }),\n        '/targets/create': wrap({\n            asyncComponent: () => import('./targets/ChooseTargetKind.svelte') as any,\n        }),\n        '/targets/:id': wrap({\n            asyncComponent: () => import('./targets/Target.svelte') as any,\n        }),\n        '/access-roles/create': wrap({\n            asyncComponent: () => import('./CreateRole.svelte') as any,\n        }),\n        '/access-roles/:id': wrap({\n            asyncComponent: () => import('./AccessRole.svelte') as any,\n        }),\n        '/admin-roles/create': wrap({\n            asyncComponent: () => import('./CreateAdminRole.svelte') as any,\n        }),\n        '/admin-roles/:id': wrap({\n            asyncComponent: () => import('./AdminRole.svelte') as any,\n        }),\n        '/users/create': wrap({\n            asyncComponent: () => import('./CreateUser.svelte') as any,\n        }),\n        '/users/:id': wrap({\n            asyncComponent: () => import('./User.svelte') as any,\n        }),\n        '/parameters': wrap({\n            asyncComponent: () => import('./Parameters.svelte') as any,\n        }),\n        '/users': wrap({\n            asyncComponent: () => import('./Users.svelte') as any,\n        }),\n        '/access-roles': wrap({\n            asyncComponent: () => import('./AccessRoles.svelte') as any,\n        }),\n        '/admin-roles': wrap({\n            asyncComponent: () => import('./AdminRoles.svelte') as any,\n        }),\n        '/targets': wrap({\n            asyncComponent: () => import('./targets/Targets.svelte') as any,\n        }),\n        '/ssh': wrap({\n            asyncComponent: () => import('./SSHKeys.svelte') as any,\n        }),\n        '/tickets': wrap({\n            asyncComponent: () => import('./Tickets.svelte') as any,\n        }),\n        '/tickets/create': wrap({\n            asyncComponent: () => import('./CreateTicket.svelte') as any,\n        }),\n        '/ldap-servers': wrap({\n            asyncComponent: () => import('./ldap/LdapServers.svelte') as any,\n        }),\n        '/ldap-servers/create': wrap({\n            asyncComponent: () => import('./ldap/CreateLdapServer.svelte') as any,\n        }),\n        '/ldap-servers/:id': wrap({\n            asyncComponent: () => import('./ldap/LdapServer.svelte') as any,\n        }),\n        '/ldap-servers/:id/users': wrap({\n            asyncComponent: () => import('./ldap/LdapUserBrowser.svelte') as any,\n        }),\n        '/target-groups/create': wrap({\n            asyncComponent: () => import('./target-groups/CreateTargetGroup.svelte') as any,\n        }),\n        '/target-groups/:id': wrap({\n            asyncComponent: () => import('./target-groups/TargetGroup.svelte') as any,\n        }),\n        '/target-groups': wrap({\n            asyncComponent: () => import('./target-groups/TargetGroups.svelte') as any,\n        }),\n    }\n\n    let sidebarMode = $state(false)\n</script>\n\n{#snippet navItems()}\n    <NavListItem\n        class=\"mb-2\"\n        title=\"Targets\"\n        description=\"Destinations for users to connect to\"\n        href=\"/config/targets\"\n        small={sidebarMode}\n    />\n\n    <NavListItem\n        class=\"mb-2\"\n        title=\"Target groups\"\n        description=\"Organize targets into groups\"\n        href=\"/config/target-groups\"\n        small={sidebarMode}\n    />\n\n    <NavListItem\n        class=\"mb-2\"\n        title=\"Users\"\n        description=\"Manage accounts and credentials\"\n        href=\"/config/users\"\n        small={sidebarMode}\n    />\n\n    <NavListItem\n        class=\"mb-2\"\n        title=\"Roles\"\n        description=\"Group users together\"\n        href=\"/config/access-roles\"\n        small={sidebarMode}\n    />\n\n    <NavListItem\n        class=\"mb-2\"\n        title=\"Admin roles\"\n        description=\"Permissions for administrators\"\n        href=\"/config/admin-roles\"\n        small={sidebarMode}\n    />\n\n    <NavListItem\n        class=\"mb-2\"\n        title=\"Tickets\"\n        description=\"Temporary access credentials\"\n        href=\"/config/tickets\"\n        small={sidebarMode}\n    />\n\n    <NavListItem\n        class=\"mb-2\"\n        title=\"SSH keys\"\n        description=\"Own keys and known hosts\"\n        href=\"/config/ssh\"\n        small={sidebarMode}\n    />\n\n    <NavListItem\n        class=\"mb-2\"\n        title=\"LDAP servers\"\n        description=\"Connect to directory services\"\n        href=\"/config/ldap-servers\"\n        small={sidebarMode}\n    />\n\n    <NavListItem\n        class=\"mb-2\"\n        title=\"Global parameters\"\n        description=\"Change instance-wide settings\"\n        href=\"/config/parameters\"\n        small={sidebarMode}\n    />\n{/snippet}\n\n<div class=\"wrapper\" class:d-none={!sidebarMode}>\n    <div class=\"sidebar\">\n        <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->\n        {@render navItems()}\n    </div>\n\n    <div class=\"main\">\n        <Router {routes} prefix=\"/config\" on:routeLoading={e => {\n            sidebarMode = e.detail.route !== ''\n        }} />\n    </div>\n</div>\n\n<div class=\"container-max-md\" class:d-none={sidebarMode}>\n    <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->\n    {@render navItems()}\n</div>\n\n<style lang=\"scss\">\n    $sb-w: 200px;\n    $sb-m: 30px;\n\n    .wrapper {\n        display: flex;\n        gap: $sb-m;\n\n        > .sidebar {\n            width: $sb-w;\n            flex: none;\n        }\n\n        > .main {\n            flex: 1 0 0;\n        }\n    }\n\n    @media (max-width: #{720px + $sb-m + $sb-w}) {\n        .sidebar {\n            display: none;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/CreateAdminRole.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type AdminRole } from 'admin/lib/api'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import { replace } from 'svelte-spa-router'\n    import { FormGroup, Input } from '@sveltestrap/sveltestrap'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import { emptyPermissions } from '../lib/store'\n    import PermissionGate from 'admin/lib/PermissionGate.svelte'\n\n    let error: string|null = $state(null)\n    let role: AdminRole = $state({\n        id: '',\n        name: '',\n        description: '',\n        ...emptyPermissions(),\n    })\n\n\n    async function create () {\n        try {\n            const r = await api.createAdminRole({ adminRoleDataRequest: role })\n            replace(`/config/admin-roles/${r.id}`)\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <PermissionGate perm=\"adminRolesManage\" message=\"You have no permission to manage admin roles.\">\n        <div class=\"page-summary-bar\">\n            <h1>create admin role</h1>\n        </div>\n\n        <div class=\"narrow-page\">\n        <FormGroup floating label=\"Name\">\n            <Input bind:value={role.name} autofocus />\n        </FormGroup>\n\n        <FormGroup floating label=\"Description\">\n            <Input bind:value={role.description} />\n        </FormGroup>\n\n\n        {#if error}\n            <Alert color=\"danger\">{error}</Alert>\n        {/if}\n\n        <div class=\"d-flex mt-3\">\n            <AsyncButton\n                color=\"primary\"\n                class=\"ms-auto\"\n                click={create}\n            >Create</AsyncButton>\n        </div>\n        </div>\n    </PermissionGate>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/CreateRole.svelte",
    "content": "<script lang=\"ts\">\nimport { api } from 'admin/lib/api'\nimport AsyncButton from 'common/AsyncButton.svelte'\nimport { replace } from 'svelte-spa-router'\nimport { Form, FormGroup } from '@sveltestrap/sveltestrap'\nimport { stringifyError } from 'common/errors'\nimport Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n\nlet error: string|null = $state(null)\nlet name = $state('')\n\nasync function create () {\n    try {\n        const role = await api.createRole({\n            roleDataRequest: {\n                name,\n            },\n        })\n        replace(`/config/access-roles/${role.id}`)\n    } catch (err) {\n        error = await stringifyError(err)\n    }\n}\n\n</script>\n\n<div class=\"container-max-md\">\n    {#if error}\n        <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    <div class=\"page-summary-bar\">\n        <h1>add a role</h1>\n    </div>\n\n    <div class=\"narrow-page\">\n        <Form>\n            <FormGroup floating label=\"Name\">\n                <!-- svelte-ignore a11y_autofocus -->\n                <input class=\"form-control\" bind:value={name} required autofocus />\n            </FormGroup>\n\n            <AsyncButton\n                color=\"primary\"\n                click={create}\n            >Create role</AsyncButton>\n        </Form>\n    </div>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/CreateTicket.svelte",
    "content": "<script lang=\"ts\">\nimport { api, type User, type Target, type TicketAndSecret } from 'admin/lib/api'\nimport AsyncButton from 'common/AsyncButton.svelte'\nimport ConnectionInstructions from 'common/ConnectionInstructions.svelte'\nimport { TargetKind } from 'gateway/lib/api'\nimport { link } from 'svelte-spa-router'\nimport { FormGroup } from '@sveltestrap/sveltestrap'\nimport { firstBy } from 'thenby'\nimport { stringifyError } from 'common/errors'\nimport Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n\nlet error: string|null = $state(null)\nlet targets: Target[]|undefined = $state()\nlet users: User[]|undefined = $state()\nlet selectedTarget: Target|undefined = $state()\nlet selectedUser: User|undefined = $state()\nlet selectedExpiry: string|undefined = $state()\nlet selectedNumberOfUses: number|undefined = $state()\nlet result: TicketAndSecret|undefined = $state()\nlet selectedDescription: string = $state('')\n\nasync function load () {\n    [targets, users] = await Promise.all([\n        api.getTargets(),\n        api.getUsers(),\n    ])\n    targets.sort(firstBy('name'))\n    users.sort(firstBy('username'))\n}\n\nload().catch(async e => {\n    error = await stringifyError(e)\n})\n\nasync function create () {\n    if (!selectedTarget || !selectedUser) {\n        return\n    }\n    try {\n        result = await api.createTicket({\n            createTicketRequest: {\n                username: selectedUser.username,\n                targetName: selectedTarget.name,\n                expiry: selectedExpiry ? new Date(selectedExpiry) : undefined,\n                numberOfUses: selectedNumberOfUses,\n                description: selectedDescription,\n            },\n        })\n    } catch (err) {\n        error = await stringifyError(err)\n    }\n}\n\n</script>\n\n<div class=\"container-max-md\">\n    {#if error}\n    <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    {#if result}\n        <div class=\"page-summary-bar\">\n            <h1>ticket created</h1>\n        </div>\n\n        <Alert color=\"warning\" fade={false} class=\"mb-3\">\n            The secret is only shown once - you won't be able to see it again.\n        </Alert>\n\n        {#if selectedTarget && selectedUser}\n        <ConnectionInstructions\n            targetName={selectedTarget.name}\n            targetKind={{\n                Http: TargetKind.Http,\n                MySql: TargetKind.MySql,\n                Ssh: TargetKind.Ssh,\n                Postgres: TargetKind.Postgres,\n                Kubernetes: TargetKind.Ssh, // Use SSH as placeholder since Kubernetes isn't in gateway TargetKind\n            }[selectedTarget.options.kind]}\n            username={selectedUser.username}\n            targetExternalHost={selectedTarget.options.kind === 'Http' ? selectedTarget.options.externalHost : undefined}\n            ticketSecret={result.secret}\n            targetDefaultDatabaseName={\n                (selectedTarget.options.kind === TargetKind.MySql || selectedTarget.options.kind === TargetKind.Postgres)\n                    ? selectedTarget.options.defaultDatabaseName : undefined}\n        />\n        {/if}\n\n        <a\n            class=\"btn btn-secondary\"\n            href=\"/config/tickets\"\n            use:link\n        >Done</a>\n    {:else}\n    <div class=\"narrow-page\">\n        <div class=\"page-summary-bar\">\n            <h1>create an access ticket</h1>\n        </div>\n\n        {#if users}\n        <FormGroup floating label=\"Authorize as user\">\n            <select bind:value={selectedUser} class=\"form-control\">\n                {#each users as user (user.id)}\n                    <option value={user}>\n                        {user.username}\n                    </option>\n                {/each}\n            </select>\n        </FormGroup>\n        {/if}\n\n        {#if targets}\n        <FormGroup floating label=\"Target\">\n            <select bind:value={selectedTarget} class=\"form-control\">\n                {#each targets as target (target.id)}\n                    <option value={target}>\n                        {target.name}\n                    </option>\n                {/each}\n            </select>\n        </FormGroup>\n        {/if}\n\n        <FormGroup floating label=\"Description\">\n            <input type=\"text\" bind:value={selectedDescription} class=\"form-control\" placeholder=\"Optional description\"/>\n        </FormGroup>\n\n        <FormGroup floating label=\"Expiry (optional)\">\n            <input type=\"datetime-local\" bind:value={selectedExpiry} class=\"form-control\"/>\n        </FormGroup>\n\n        <FormGroup floating label=\"Number of uses (optional)\">\n            <input type=\"number\" min=\"1\" bind:value={selectedNumberOfUses} class=\"form-control\"/>\n        </FormGroup>\n\n        <AsyncButton\n        color=\"primary\"\n            click={create}\n        >Create ticket</AsyncButton>\n    </div>\n    {/if}\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/CreateUser.svelte",
    "content": "<script lang=\"ts\">\nimport { api } from 'admin/lib/api'\nimport { adminPermissions } from '../lib/store'\nimport AsyncButton from 'common/AsyncButton.svelte'\nimport { replace } from 'svelte-spa-router'\nimport { Form, FormGroup } from '@sveltestrap/sveltestrap'\nimport { stringifyError } from 'common/errors'\nimport Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n\nlet error: string|null = $state(null)\nlet username = $state('')\n\nasync function create () {\n    try {\n        const user = await api.createUser({\n            createUserRequest: {\n                username,\n            },\n        })\n        replace(`/config/users/${user.id}`)\n    } catch (err) {\n        error = await stringifyError(err)\n    }\n}\n\n</script>\n\n<div class=\"container-max-md\">\n    {#if error}\n    <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    <div class=\"page-summary-bar\">\n        <h1>add a user</h1>\n        {#if !$adminPermissions.usersCreate}\n            <Alert color=\"warning\">You do not have permission to create users.</Alert>\n        {/if}\n    </div>\n    <div class=\"narrow-page\">\n        <Form>\n            <FormGroup floating label=\"Username\">\n                <input class=\"form-control\" required bind:value={username} />\n            </FormGroup>\n\n            <AsyncButton\n            color=\"primary\"\n                click={create}\n                disabled={!$adminPermissions.usersCreate}\n            >Create user</AsyncButton>\n        </Form>\n    </div>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/Parameters.svelte",
    "content": "<script lang=\"ts\">\n    import { FormGroup, Input } from '@sveltestrap/sveltestrap'\n    import { api, type ParameterValues } from 'admin/lib/api'\n    import { api as gatewayApi } from 'gateway/lib/api'\n    import Loadable from 'common/Loadable.svelte'\n    import RateLimitInput from 'common/RateLimitInput.svelte'\n    import InfoBox from 'common/InfoBox.svelte'\n    import PermissionGate from 'admin/lib/PermissionGate.svelte'\n\n    let parameters: ParameterValues | undefined = $state()\n    let hasSsoProviders = $state(false)\n    const initPromise = init()\n\n    async function init () {\n        parameters = await api.getParameters({})\n        const ssoProviders = await gatewayApi.getSsoProviders()\n        hasSsoProviders = ssoProviders.length > 0\n    }\n\n    async function update() {\n        await api.updateParameters({\n            parameterUpdate: parameters!,\n        })\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <div class=\"page-summary-bar\">\n        <h1>global parameters</h1>\n    </div>\n\n    <PermissionGate perm=\"configEdit\" message=\"You have no permission to edit global parameters.\">\n        <Loadable promise={initPromise}>\n        {#if parameters}\n            <h4 class=\"mt-4\">Credentials</h4>\n            <label\n                for=\"allowOwnCredentialManagement\"\n                class=\"d-flex align-items-center\"\n            >\n                <Input\n                    id=\"allowOwnCredentialManagement\"\n                    class=\"mb-0 me-2\"\n                    type=\"switch\"\n                    on:change={() => {\n                        parameters!.allowOwnCredentialManagement = !parameters!.allowOwnCredentialManagement\n                        update()\n                    }}\n                    checked={parameters.allowOwnCredentialManagement} />\n                <div>Allow users to manage their own credentials</div>\n            </label>\n\n            <h4 class=\"mt-4\">Traffic</h4>\n            <FormGroup>\n                <label class=\"mb-2\" for=\"rateLimitBytesPerSecond\">Global bandwidth limit</label>\n                <RateLimitInput\n                    id=\"rateLimitBytesPerSecond\"\n                    bind:value={parameters.rateLimitBytesPerSecond}\n                    change={update} />\n            </FormGroup>\n\n            <h4 class=\"mt-4\">SSH</h4>\n            <!-- svelte-ignore a11y_label_has_associated_control -->\n            <label class=\"mb-2\">Allowed authentication methods</label>\n            <label\n                for=\"sshClientAuthPublickey\"\n                class=\"d-flex align-items-center mb-2\"\n            >\n                <Input\n                    id=\"sshClientAuthPublickey\"\n                    class=\"mb-0 me-2\"\n                    type=\"switch\"\n                    on:change={() => {\n                        parameters!.sshClientAuthPublickey = !parameters!.sshClientAuthPublickey\n                        update()\n                    }}\n                    checked={parameters.sshClientAuthPublickey} />\n                <div>Public key authentication</div>\n            </label>\n            <label\n                for=\"sshClientAuthPassword\"\n                class=\"d-flex align-items-center mb-2\"\n            >\n                <Input\n                    id=\"sshClientAuthPassword\"\n                    class=\"mb-0 me-2\"\n                    type=\"switch\"\n                    on:change={() => {\n                        parameters!.sshClientAuthPassword = !parameters!.sshClientAuthPassword\n                        update()\n                    }}\n                    checked={parameters.sshClientAuthPassword} />\n                <div>Password authentication</div>\n            </label>\n            <label\n                for=\"sshClientAuthKeyboardInteractive\"\n                class=\"d-flex align-items-center\"\n            >\n                <Input\n                    id=\"sshClientAuthKeyboardInteractive\"\n                    class=\"mb-0 me-2\"\n                    type=\"switch\"\n                    on:change={() => {\n                        parameters!.sshClientAuthKeyboardInteractive = !parameters!.sshClientAuthKeyboardInteractive\n                        update()\n                    }}\n                    checked={parameters.sshClientAuthKeyboardInteractive} />\n                <div>Keyboard-interactive authentication (OTP, 2FA prompts)</div>\n            </label>\n            <InfoBox class=\"mt-3 mb-3\">\n                Controls which authentication methods are offered to SSH clients.\n                Disabling password authentication can help prevent brute-force attacks.\n            </InfoBox>\n\n            {#if hasSsoProviders}\n            <h4 class=\"mt-4\">Login</h4>\n            <label\n                for=\"minimizePasswordLogin\"\n                class=\"d-flex align-items-center\"\n            >\n                <Input\n                    id=\"minimizePasswordLogin\"\n                    class=\"mb-0 me-2\"\n                    type=\"switch\"\n                    on:change={() => {\n                        parameters!.minimizePasswordLogin = !parameters!.minimizePasswordLogin\n                        update()\n                    }}\n                    checked={parameters.minimizePasswordLogin} />\n                <div>Minimize password login UI</div>\n            </label>\n            <InfoBox class=\"mt-3 mb-3\">\n                When enabled, the username and password fields are hidden behind a link on the login page, with the focus on the SSO buttons.\n            </InfoBox>\n            {/if}\n        {/if}\n        </Loadable>\n    </PermissionGate>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/SSHKeys.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type SSHKey, type SSHKnownHost } from 'admin/lib/api'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import CopyButton from 'common/CopyButton.svelte'\n    import { stringifyError } from 'common/errors'\n    import { Button } from '@sveltestrap/sveltestrap'\n    import { adminPermissions } from 'admin/lib/store'\n\n    let error: string|undefined = $state()\n    let knownHosts: SSHKnownHost[]|undefined = $state()\n    let ownKeys: SSHKey[]|undefined = $state()\n\n    async function load () {\n        ownKeys = await api.getSshOwnKeys()\n        if ($adminPermissions.configEdit) {\n            knownHosts = await api.getSshKnownHosts()\n        }\n    }\n\n    load().catch(async e => {\n        error = await stringifyError(e)\n    })\n\n    async function deleteHost (host: SSHKnownHost) {\n        await api.deleteSshKnownHost(host)\n        load()\n    }\n\n</script>\n\n<div class=\"page-summary-bar\">\n    <h1>SSH</h1>\n</div>\n\n{#if error}\n    <Alert color=\"danger\">{error}</Alert>\n{/if}\n\n{#if ownKeys}\n    <h2>Warpgate's own SSH keys</h2>\n    <Alert color=\"info\">Add these keys to the targets' <code>authorized_keys</code> files</Alert>\n    <div class=\"list-group list-group-flush\">\n        {#each ownKeys as key (key)}\n            <div class=\"list-group-item d-flex\">\n                <pre>{key.kind} {key.publicKeyBase64}</pre>\n                <div class=\"ms-auto\">\n                    <CopyButton class=\"ms-3 px-0\" text={key.kind + ' ' + key.publicKeyBase64} />\n                </div>\n            </div>\n        {/each}\n    </div>\n{/if}\n\n<div class=\"mb-3\"></div>\n{#if knownHosts}\n    {#if knownHosts.length }\n        <h2>Known hosts: {knownHosts.length}</h2>\n    {:else}\n        <h2>No known hosts</h2>\n    {/if}\n    <div class=\"list-group list-group-flush\">\n        {#each knownHosts as host (host.id)}\n            <div class=\"list-group-item\">\n                <div class=\"d-flex\">\n                    <strong>\n                        {host.host}:{host.port}\n                    </strong>\n\n                    <Button\n                        class=\"ms-auto\"\n                        color=\"link px-0\"\n                        onclick={e => {\n                            e.preventDefault()\n                            deleteHost(host)\n                        }}\n                        disabled={!$adminPermissions.configEdit}\n                    >Delete</Button>\n                </div>\n                <pre>{host.keyType} {host.keyBase64}</pre>\n            </div>\n        {/each}\n    </div>\n{/if}\n\n<style lang=\"scss\">\n    pre {\n        word-break: break-word;\n        white-space: normal;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/Tickets.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type Ticket } from 'admin/lib/api'\n    import { link } from 'svelte-spa-router'\n    import RelativeDate from '../RelativeDate.svelte'\n    import Fa from 'svelte-fa'\n    import { faCalendarXmark, faCalendarCheck, faSquareXmark, faSquareCheck } from '@fortawesome/free-solid-svg-icons'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import EmptyState from 'common/EmptyState.svelte'\n    import { Button } from '@sveltestrap/sveltestrap'\n    import { adminPermissions } from 'admin/lib/store'\n\n    let error: string|undefined = $state()\n    let tickets: Ticket[]|undefined = $state()\n\n    async function load () {\n        tickets = await api.getTickets()\n    }\n\n    load().catch(async e => {\n        error = await stringifyError(e)\n    })\n\n    async function deleteTicket (ticket: Ticket) {\n        await api.deleteTicket(ticket)\n        load()\n    }\n</script>\n\n<div class=\"container-max-lg\">\n    {#if error}\n    <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    {#if tickets}\n        <div class=\"page-summary-bar\">\n            {#if tickets.length }\n                <h1>access tickets: <span class=\"counter\">{tickets.length}</span></h1>\n            {:else}\n                <h1>access tickets</h1>\n            {/if}\n            <a\n                class=\"btn btn-primary ms-auto\"\n                href=\"/config/tickets/create\"\n                class:disabled={!$adminPermissions.ticketsCreate}\n                use:link>\n                Create a ticket\n            </a>\n        </div>\n\n        {#if tickets.length}\n            <div class=\"list-group list-group-flush\">\n                {#each tickets as ticket (ticket.id)}\n                    <div class=\"list-group-item\">\n                        <div>\n                            <strong>\n                                Access to {ticket.target} as {ticket.username}\n                            </strong>\n                            {#if ticket.description}\n                                <small class=\"d-block text-muted\">\n                                    {ticket.description}\n                                </small>\n                            {/if}\n                        </div>\n                        {#if ticket.expiry}\n                            <small class=\"text-muted ms-4 d-flex align-items-center\">\n                                <Fa icon={ticket.expiry > new Date() ? faCalendarCheck : faCalendarXmark} fw /> Until {ticket.expiry?.toLocaleString()}\n                            </small>\n                        {/if}\n                        {#if ticket.usesLeft != null}\n                            {#if ticket.usesLeft > 0}\n                                <small class=\"text-muted ms-4 d-flex align-items-center\">\n                                    <Fa icon={faSquareCheck} fw /> Uses left: {ticket.usesLeft}\n                                </small>\n                            {/if}\n                            {#if ticket.usesLeft === 0}\n                                <small class=\"text-danger ms-4\">\n                                    <Fa icon={faSquareXmark} fw /> Used up\n                                </small>\n                            {/if}\n                        {/if}\n                        <small class=\"text-muted me-4 ms-auto\">\n                            <RelativeDate date={ticket.created} />\n                        </small>\n                        <Button\n                            color=\"link\"\n                            onclick={e => {\n                                deleteTicket(ticket)\n                                e.preventDefault()\n                            }}\n                            disabled={!$adminPermissions.ticketsDelete}\n                        >Delete</Button>\n                    </div>\n                {/each}\n            </div>\n        {:else}\n            <EmptyState\n                title=\"No tickets yet\"\n                hint=\"Tickets are secret keys that allow access to one specific target without any additional authentication\"\n            />\n        {/if}\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .list-group-item {\n        display: flex;\n        align-items: center;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/User.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type Role, type User, type AdminRole } from 'admin/lib/api'\n    import { serverInfo } from 'gateway/lib/store'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import { replace } from 'svelte-spa-router'\n    import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle, FormGroup, Input } from '@sveltestrap/sveltestrap'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import CredentialEditor from '../CredentialEditor.svelte'\n    import Loadable from 'common/Loadable.svelte'\n    import RateLimitInput from 'common/RateLimitInput.svelte'\n    import Fa from 'svelte-fa'\n    import { faCaretDown, faLink, faUnlink } from '@fortawesome/free-solid-svg-icons'\n    import { adminPermissions } from 'admin/lib/store'\n    import AdminRolePermissionsBadge from './AdminRolePermissionsBadge.svelte'\n\n    interface Props {\n        params: { id: string };\n    }\n\n    let { params }: Props = $props()\n\n    let error: string|null = $state(null)\n    let user: User | undefined = $state()\n    let allRoles: Role[] = $state([])\n    let roleIsAllowed: Record<string, any> = $state({})\n\n    let allAdminRoles: AdminRole[] = $state([])\n    let adminRoleIsAllowed: Record<string, any> = $state({})\n\n    const initPromise = init()\n\n    async function init () {\n        user = await api.getUser({ id: params.id })\n        user.credentialPolicy ??= {}\n\n        allRoles = await api.getRoles()\n        const allowedRoles = await api.getUserRoles(user)\n        roleIsAllowed = Object.fromEntries(allowedRoles.map(r => [r.id, true]))\n\n        allAdminRoles = await api.getAdminRoles()\n        const allowedAdmins = await api.getUserAdminRoles({ id: user.id })\n        adminRoleIsAllowed = Object.fromEntries(allowedAdmins.map(r => [r.id, true]))\n    }\n\n    async function update () {\n        try {\n            user = await api.updateUser({\n                id: params.id,\n                userDataRequest: user!,\n            })\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    }\n\n    async function remove () {\n        if (confirm(`Delete user ${user!.username}?`)) {\n            await api.deleteUser(user!)\n            replace('/config/users')\n        }\n    }\n\n    async function toggleRole (role: Role) {\n        if (roleIsAllowed[role.id]) {\n            await api.deleteUserRole({\n                id: user!.id,\n                roleId: role.id,\n            })\n            roleIsAllowed = { ...roleIsAllowed, [role.id]: false }\n        } else {\n            await api.addUserRole({\n                id: user!.id,\n                roleId: role.id,\n            })\n            roleIsAllowed = { ...roleIsAllowed, [role.id]: true }\n        }\n    }\n\n    async function toggleAdminRole (role: AdminRole) {\n        if (adminRoleIsAllowed[role.id]) {\n            await api.deleteUserAdminRole({\n                id: user!.id,\n                roleId: role.id,\n            })\n            adminRoleIsAllowed = { ...adminRoleIsAllowed, [role.id]: false }\n        } else {\n            await api.addUserAdminRole({\n                id: user!.id,\n                roleId: role.id,\n            })\n            adminRoleIsAllowed = { ...adminRoleIsAllowed, [role.id]: true }\n        }\n    }\n\n    async function unlinkFromLdap () {\n        try {\n            user = await api.unlinkUserFromLdap({ id: params.id })\n            error = null\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    }\n\n    async function autoLinkToLdap () {\n        try {\n            user = await api.autoLinkUserToLdap({ id: params.id })\n            error = null\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <Loadable promise={initPromise}>\n    {#if user}\n    <div class=\"page-summary-bar\">\n        <div>\n            <h1>{user.username}</h1>\n            <div class=\"text-muted\">User</div>\n        </div>\n    </div>\n\n    <div class=\"d-flex align-items-center gap-3\">\n        <FormGroup floating label=\"Username\" class=\"flex-grow-1\">\n            <Input bind:value={user.username} disabled={!user.ldapServerId} />\n        </FormGroup>\n\n        {#if $serverInfo?.hasLdap}\n        <Dropdown class=\"mb-3\">\n            <DropdownToggle color={user.ldapServerId ? 'info' : 'secondary'} class=\"d-flex align-items-center gap-2\">\n                {#if user.ldapServerId}\n                    <Fa icon={faLink} fw />\n                {/if}\n                LDAP\n                <Fa icon={faCaretDown} />\n            </DropdownToggle>\n            <DropdownMenu right={true}>\n                {#if user.ldapServerId}\n                <DropdownItem on:click={unlinkFromLdap}>\n                    <Fa icon={faUnlink} fw />\n                    Unlink from LDAP\n                </DropdownItem>\n                {:else}\n                <DropdownItem on:click={autoLinkToLdap}>\n                    <Fa icon={faLink} fw />\n                    Auto-link to LDAP\n                </DropdownItem>\n                {/if}\n            </DropdownMenu>\n        </Dropdown>\n        {/if}\n    </div>\n\n    <FormGroup floating label=\"Description\">\n        <Input bind:value={user.description} />\n    </FormGroup>\n\n    {#if $adminPermissions.usersEdit}\n    <CredentialEditor\n        userId={user.id}\n        username={user.username}\n        bind:credentialPolicy={user.credentialPolicy!}\n        ldapLinked={!!user.ldapServerId}\n    />\n    {/if}\n\n    <h4 class=\"mt-4\">User roles</h4>\n    <div class=\"list-group list-group-flush mb-3\">\n        {#each allRoles as role (role.id)}\n            <label\n                for=\"role-{role.id}\"\n                class=\"list-group-item list-group-item-action d-flex align-items-center\"\n            >\n                <Input\n                    id=\"role-{role.id}\"\n                    class=\"mb-0 me-2\"\n                    type=\"switch\"\n                    on:change={() => toggleRole(role)}\n                    disabled={!$adminPermissions.accessRolesAssign}\n                    checked={roleIsAllowed[role.id]} />\n                <div>\n                    <div>{role.name}</div>\n                    {#if role.description}\n                        <small class=\"text-muted\">{role.description}</small>\n                    {/if}\n                </div>\n            </label>\n        {/each}\n    </div>\n\n    <h4 class=\"mt-4\">Admin roles</h4>\n    <div class=\"list-group list-group-flush mb-3\">\n        {#each allAdminRoles as role (role.id)}\n            <label\n                for=\"admin-role-{role.id}\"\n                class=\"list-group-item list-group-item-action d-flex align-items-center\"\n            >\n                <Input\n                    id=\"admin-role-{role.id}\"\n                    class=\"mb-0 me-2\"\n                    type=\"switch\"\n                    on:change={() => toggleAdminRole(role)}\n                    disabled={!$adminPermissions.adminRolesManage}\n                    checked={adminRoleIsAllowed[role.id]} />\n                <div>\n                    <div>{role.name}</div>\n                    {#if role.description}\n                        <small class=\"text-muted\">{role.description}</small>\n                    {/if}\n                </div   >\n                <span class=\"ms-auto\">\n                    <AdminRolePermissionsBadge {role} />\n                </span>\n            </label>\n        {/each}\n    </div>\n\n    <h4 class=\"mt-4\">Traffic</h4>\n    <FormGroup class=\"mb-5\">\n        <label for=\"rateLimitBytesPerSecond\">Global bandwidth limit</label>\n        <RateLimitInput\n            id=\"rateLimitBytesPerSecond\"\n            bind:value={user.rateLimitBytesPerSecond}\n        />\n    </FormGroup>\n    {/if}\n    </Loadable>\n\n    {#if error}\n        <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    <div class=\"d-flex\">\n        <AsyncButton\n        color=\"primary\"\n            class=\"ms-auto\"\n            click={update}\n            disabled={!$adminPermissions.usersEdit}\n        >Update</AsyncButton>\n\n        <AsyncButton\n            class=\"ms-2\"\n            color=\"danger\"\n            click={remove}\n            disabled={!$adminPermissions.usersDelete}\n        >Remove</AsyncButton>\n    </div>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/Users.svelte",
    "content": "<script lang=\"ts\">\n    import { Observable, from, map } from 'rxjs'\n    import { type LdapServerResponse, type User, api } from 'admin/lib/api'\n    import { adminPermissions } from '../lib/store'\n    import ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'\n    import { link, push } from 'svelte-spa-router'\n    import { onMount } from 'svelte'\n    import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from '@sveltestrap/sveltestrap'\n    import { compare as naturalCompareFactory } from 'natural-orderby'\n\n    let ldapServers = $state<LdapServerResponse[]>([])\n\n    function getUsers(options: LoadOptions): Observable<PaginatedResponse<User>> {\n        return from(\n            api.getUsers({\n                search: options.search,\n            })\n        ).pipe(\n            map(users => {\n                const sorted = users.sort((a, b) =>\n                    naturalCompareFactory()(\n                        a.username.toLowerCase(),\n                        b.username.toLowerCase()\n                    )\n                )\n\n                return {\n                    items: sorted,\n                    offset: 0,\n                    total: sorted.length,\n                }\n            })\n        )\n    }\n\n    onMount(() => {\n        api.getLdapServers().then(servers => {\n            ldapServers = servers\n        })\n    })\n</script>\n\n<div class=\"container-max-md\">\n    <div class=\"page-summary-bar\">\n        <h1>users</h1>\n            <a\n                class=\"btn btn-primary ms-auto\"\n                href=\"/config/users/create\"\n                class:disabled={!$adminPermissions.usersCreate}\n                use:link>\n                Add a user\n            </a>\n            {#if ldapServers.length > 0}\n            <Dropdown>\n                <DropdownToggle caret>\n                    Add from LDAP\n                </DropdownToggle>\n                <DropdownMenu>\n                    {#each ldapServers as server (server.id)}\n                        <DropdownItem\n                            onclick={() => {\n                                push(`/config/ldap-servers/${server.id}/users`)\n                            }}\n                            disabled={!$adminPermissions.usersCreate}\n                        >\n                            {server.name}\n                        </DropdownItem>\n                    {/each}\n                </DropdownMenu>\n            </Dropdown>\n            {/if}\n    </div>\n\n    <ItemList load={getUsers} showSearch={true}>\n        {#snippet item(user)}\n            <a\n                class=\"list-group-item list-group-item-action\"\n                href=\"/config/users/{user.id}\"\n                use:link>\n                <div>\n                    <strong class=\"me-auto\">\n                        {user.username}\n                    </strong>\n                    {#if user.description}\n                    <small class=\"d-block text-muted\">{user.description}</small>\n                    {/if}\n                </div>\n                {#if user.ldapServerId}\n                    <span class=\"badge bg-info ms-auto\">\n                        LDAP\n                    </span>\n                {/if}\n            </a>\n        {/snippet}\n    </ItemList>\n</div>\n\n<style lang=\"scss\">\n    .list-group-item {\n        display: flex;\n        align-items: center;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/ldap/CreateLdapServer.svelte",
    "content": "<script lang=\"ts\">\n    import { FormGroup, Input } from '@sveltestrap/sveltestrap'\n    import { push } from 'svelte-spa-router'\n    import { reloadServerInfo } from 'gateway/lib/store'\n    import { api, LdapUsernameAttribute, stringifyError, TlsMode, type Tls } from 'admin/lib/api'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import LdapConnectionFields from './LdapConnectionFields.svelte'\n    import { defaultLdapPortForTlsMode, testLdapConnection } from './common'\n\n    let name = $state('')\n    let host = $state('')\n    // eslint-disable-next-line svelte/prefer-writable-derived\n    let port = $state(389)\n    let bindDn = $state('')\n    let bindPassword = $state('')\n    let userFilter = $state('(objectClass=person)')\n    let enabled = $state(true)\n    let autoLinkSsoUsers = $state(false)\n    let description = $state('')\n    let usernameAttribute = $state(LdapUsernameAttribute.Cn)\n    let sshKeyAttribute = $state('sshPublicKey')\n    let uuidAttribute = $state('')\n    let error = $state<string | null>(null)\n    let tls: Tls = $state({\n        mode: TlsMode.Preferred,\n        verify: true,\n    })\n    let testResult = $state<{ success: boolean; message: string; baseDns?: string[] } | null>(null)\n\n    // Auto-update port based on TLS mode\n    $effect(() => {\n        port = defaultLdapPortForTlsMode(tls.mode)\n    })\n\n    async function testConnection() {\n        error = null\n        testResult = null\n\n        try {\n            testResult = await testLdapConnection({\n                host,\n                port,\n                bindDn,\n                bindPassword,\n                tlsMode: tls.mode,\n                tlsVerify: tls.verify,\n            })\n        } catch (e: any) {\n            error = await stringifyError(e)\n        }\n    }\n\n    async function create() {\n        error = null\n\n        try {\n            const result = await api.createLdapServer({\n                createLdapServerRequest: {\n                    name,\n                    host,\n                    port,\n                    bindDn,\n                    bindPassword,\n                    userFilter,\n                    tlsMode: tls.mode,\n                    tlsVerify: tls.verify,\n                    enabled,\n                    autoLinkSsoUsers,\n                    description: description || undefined,\n                    usernameAttribute,\n                    sshKeyAttribute,\n                    uuidAttribute,\n                },\n            })\n\n            reloadServerInfo() // update hasLdap flag\n            push(`/config/ldap-servers/${result.id}`)\n        } catch (e: any) {\n            error = await stringifyError(e)\n        }\n    }\n</script>\n\n<div class=\"container-max-md\">\n    {#if error}\n        <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    <div class=\"page-summary-bar\">\n        <h1>add an LDAP server</h1>\n    </div>\n\n    <form onsubmit={e => {e.preventDefault(); create()}}>\n        <FormGroup floating label=\"Name\">\n            <Input\n                bind:value={name}\n                required\n            />\n        </FormGroup>\n\n        <LdapConnectionFields\n            bind:host\n            bind:port\n            bind:bindDn\n            bind:bindPassword\n            bind:tls\n            bind:userFilter\n            bind:usernameAttribute\n            bind:sshKeyAttribute\n            bind:uuidAttribute\n        />\n\n        {#if testResult}\n            <div class=\"alert {testResult.success ? 'alert-success' : 'alert-danger'}\" role=\"alert\">\n                {testResult.message}\n                {#if testResult.baseDns && testResult.baseDns.length > 0}\n                    <div class=\"mt-2\">\n                        <strong>Discovered Base DNs:</strong>\n                        <ul class=\"mb-0 mt-1\">\n                            {#each testResult.baseDns as dn (dn)}\n                                <li><code>{dn}</code></li>\n                            {/each}\n                        </ul>\n                    </div>\n                {/if}\n            </div>\n        {/if}\n\n        <div class=\"d-flex gap-2 mt-5\">\n            <AsyncButton type=\"button\" class=\"me-auto\" click={testConnection}>\n                Test connection\n            </AsyncButton>\n            <AsyncButton type=\"submit\" color=\"primary\" click={create}>\n                Create\n            </AsyncButton>\n        </div>\n    </form>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/ldap/LdapConnectionFields.svelte",
    "content": "<script lang=\"ts\">\n    import { FormGroup, Input } from '@sveltestrap/sveltestrap'\n    import TlsConfiguration from 'admin/TlsConfiguration.svelte'\n    import { type Tls, LdapUsernameAttribute } from 'admin/lib/api'\n\n    interface Props {\n        host: string\n        port: number\n        bindDn: string\n        bindPassword: string\n        tls: Tls\n        userFilter: string\n        passwordPlaceholder?: string\n        passwordRequired?: boolean\n        usernameAttribute: LdapUsernameAttribute,\n        sshKeyAttribute: string,\n        uuidAttribute: string,\n    }\n\n    let {\n        host = $bindable(),\n        port = $bindable(),\n        bindDn = $bindable(),\n        bindPassword = $bindable(),\n        usernameAttribute = $bindable(),\n        sshKeyAttribute = $bindable(),\n        uuidAttribute = $bindable(),\n        tls = $bindable(),\n        userFilter = $bindable(),\n        passwordPlaceholder = undefined,\n        passwordRequired = true,\n    }: Props = $props()\n\n    const usernameAttributeOptions = [\n        { value: LdapUsernameAttribute.Cn, name: 'CN' },\n        { value: LdapUsernameAttribute.Email, name: 'E-mail' },\n        { value: LdapUsernameAttribute.UserPrincipalName, name: 'User principal name' },\n        { value: LdapUsernameAttribute.SamAccountName, name: 'SAM account name' },\n        { value: LdapUsernameAttribute.Uid, name: 'UID' },\n    ]\n</script>\n\n<div class=\"mt-4\">\n    <div class=\"row\">\n        <div class=\"col-md-8 d-flex align-items-center gap-3\">\n            <FormGroup floating label=\"Host\" class=\"flex-grow-1\">\n                <Input bind:value={host} required />\n            </FormGroup>\n        </div>\n        <div class=\"col-md-4\">\n            <FormGroup floating label=\"Port\">\n                <Input bind:value={port} type=\"number\" required />\n            </FormGroup>\n        </div>\n    </div>\n    <TlsConfiguration bind:value={tls} class=\"flex-grow-1\" />\n</div>\n\n<div class=\"mt-4 row\">\n    <div class=\"col-md-6\">\n        <FormGroup floating label=\"Bind username / DN\">\n            <Input\n                bind:value={bindDn}\n                required\n            />\n        </FormGroup>\n    </div>\n    <div class=\"col-md-6\">\n        <FormGroup floating label=\"Bind password\">\n            <Input\n                type=\"password\"\n                bind:value={bindPassword}\n                placeholder={passwordPlaceholder}\n                required={passwordRequired}\n            />\n        </FormGroup>\n    </div>\n</div>\n\n<div class=\"mt-4 row\">\n    <div class=\"col-md-6\">\n        <FormGroup floating label=\"User query filter\">\n            <Input\n                bind:value={userFilter}\n            />\n        </FormGroup>\n    </div>\n    <div class=\"col-md-6\">\n        <FormGroup floating label=\"LDAP attribute to read usernames from\">\n            <select\n                class=\"form-control\"\n                bind:value={usernameAttribute}\n            >\n                {#each usernameAttributeOptions as item (item.value)}\n                    <option value={item.value}>{item.name}</option>\n                {/each}\n            </select>\n        </FormGroup>\n        <FormGroup floating label=\"LDAP attribute to read SSH keys from\">\n            <Input bind:value={sshKeyAttribute} placeholder=\"sshPublicKey\" />\n        </FormGroup>\n        <FormGroup floating label=\"LDAP object UUID attribute\">\n            <Input bind:value={uuidAttribute} placeholder=\"Automatic (objectGUID / entryUUID)\" />\n        </FormGroup>\n    </div>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/ldap/LdapServer.svelte",
    "content": "<script lang=\"ts\">\n    import { push } from 'svelte-spa-router'\n    import { api, LdapUsernameAttribute, stringifyError, TlsMode, type LdapServerResponse, type Tls } from 'admin/lib/api'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import Loadable from 'common/Loadable.svelte'\n    import { FormGroup, Input } from '@sveltestrap/sveltestrap'\n    import LdapConnectionFields from './LdapConnectionFields.svelte'\n    import { defaultLdapPortForTlsMode, testLdapConnection } from './common'\n\n    interface Props {\n        params: { id: string }\n    }\n\n    let { params }: Props = $props()\n\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    let server = $state<LdapServerResponse | null>(null)\n    let name = $state('')\n    let host = $state('')\n    let port = $state(389)\n    let bindDn = $state('')\n    let bindPassword = $state('')\n    let userFilter = $state('')\n    let baseDns = $state<string[]>([])\n    let tls: Tls = $state({\n        mode: TlsMode.Preferred,\n        verify: true,\n    })\n    let enabled = $state(true)\n    let autoLinkSsoUsers = $state(false)\n    let description = $state('')\n    let usernameAttribute = $state<LdapUsernameAttribute>(LdapUsernameAttribute.Cn)\n    let sshKeyAttribute = $state('sshPublicKey')\n    let uuidAttribute = $state('')\n    let error = $state<string | null>(null)\n    let testResult = $state<{ success: boolean; message: string } | null>(null)\n    let isLoaded = $state(false)\n\n    // Auto-update port based on TLS mode (only after initial load)\n    $effect(() => {\n        if (isLoaded) {\n            port = defaultLdapPortForTlsMode(tls.mode)\n        }\n    })\n\n    async function load() {\n        const result = await api.getLdapServer({ id: params.id })\n        server = result\n        name = result.name\n        host = result.host\n        port = result.port\n        bindDn = result.bindDn\n        userFilter = result.userFilter\n        baseDns = result.baseDns || []\n        tls = { mode: result.tlsMode, verify: result.tlsVerify }\n        enabled = result.enabled\n        autoLinkSsoUsers = result.autoLinkSsoUsers\n        description = result.description || ''\n        sshKeyAttribute = result.sshKeyAttribute || 'sshPublicKey'\n        uuidAttribute = result.uuidAttribute || ''\n        usernameAttribute = result.usernameAttribute\n        isLoaded = true\n    }\n\n    async function testConnection() {\n        error = null\n        testResult = null\n\n        if (!bindPassword) {\n            error = 'Password is required to test the connection'\n            return\n        }\n\n        try {\n            testResult = await testLdapConnection({\n                host,\n                port,\n                bindDn,\n                bindPassword,\n                tlsMode: tls.mode,\n                tlsVerify: tls.verify,\n            })\n        } catch (e: any) {\n            error = await stringifyError(e)\n        }\n    }\n\n    async function save() {\n        error = null\n\n        try {\n            await api.updateLdapServer({\n                id: params.id,\n                updateLdapServerRequest: {\n                    name,\n                    host,\n                    port,\n                    bindDn,\n                    bindPassword: bindPassword || undefined,\n                    userFilter,\n                    tlsMode: tls.mode,\n                    tlsVerify: tls.verify,\n                    enabled,\n                    autoLinkSsoUsers,\n                    description: description || undefined,\n                    usernameAttribute,\n                    sshKeyAttribute,\n                    uuidAttribute,\n                },\n            })\n            await load()\n            bindPassword = ''\n        } catch (e: any) {\n            error = await stringifyError(e)\n        }\n    }\n\n    async function remove() {\n        if (!confirm('Are you sure you want to delete this LDAP server?')) {\n            return\n        }\n\n        try {\n            await api.deleteLdapServer({ id: params.id })\n            push('/config/ldap-servers')\n        } catch (e: any) {\n            error = await stringifyError(e)\n        }\n    }\n\n    async function importUsers () {\n        await save()\n        push(`/config/ldap-servers/${params.id}/users`)\n    }\n</script>\n\n<Loadable promise={load()}>\n    <div class=\"container-max-md\">\n        <div class=\"page-summary-bar\">\n            <div>\n                <h1>{name}</h1>\n                <div class=\"text-muted\">LDAP server</div>\n            </div>\n        </div>\n\n        <form onsubmit={(e) => { e.preventDefault(); save() }}>\n            <FormGroup floating label=\"Name\">\n                <Input bind:value={name} required />\n            </FormGroup>\n\n            <FormGroup floating label=\"Description\">\n                <Input bind:value={description} />\n            </FormGroup>\n\n            <LdapConnectionFields\n                bind:host\n                bind:port\n                bind:bindDn\n                bind:bindPassword\n                bind:tls\n                bind:userFilter\n                bind:usernameAttribute\n                bind:sshKeyAttribute\n                bind:uuidAttribute\n                passwordPlaceholder=\"Keep current password\"\n                passwordRequired={false}\n            />\n\n            {#if baseDns.length > 0}\n                <div class=\"mt-4\">\n                    <!-- svelte-ignore a11y_label_has_associated_control -->\n                    <label class=\"form-label\">Base DNs (discovered)</label>\n                    <ul class=\"list-group\">\n                        {#each baseDns as dn (dn)}\n                            <li class=\"list-group-item\">\n                                <code>{dn}</code>\n                            </li>\n                        {/each}\n                    </ul>\n                </div>\n            {/if}\n\n            <!-- <div class=\"mt-4\">\n                <div class=\"form-check form-switch\">\n                    <input\n                        class=\"form-check-input\"\n                        type=\"checkbox\"\n                        id=\"enabled\"\n                        bind:checked={enabled}\n                    />\n                    <label class=\"form-check-label\" for=\"enabled\">\n                        Enabled\n                    </label>\n                </div>\n            </div> -->\n\n            <div class=\"mt-4\">\n                <div class=\"form-check form-switch\">\n                    <input\n                        class=\"form-check-input\"\n                        type=\"checkbox\"\n                        id=\"autoLinkSsoUsers\"\n                        bind:checked={autoLinkSsoUsers}\n                    />\n                    <label class=\"form-check-label\" for=\"autoLinkSsoUsers\">\n                        Auto-link SSO users\n                    </label>\n                </div>\n                <div class=\"form-text\">\n                    Automatically link SSO users to their LDAP accounts when they log in\n                </div>\n            </div>\n\n            {#if testResult}\n                <div class=\"alert {testResult.success ? 'alert-success' : 'alert-danger'}\" role=\"alert\">\n                    {testResult.message}\n                </div>\n            {/if}\n\n            {#if error}\n                <div class=\"alert alert-danger mt-3\" role=\"alert\">\n                    {error}\n                </div>\n            {/if}\n\n            <div class=\"d-flex gap-2 mt-5\">\n                <AsyncButton type=\"button\" class=\"btn btn-secondary\" click={testConnection}>\n                    Test Connection\n                </AsyncButton>\n                <AsyncButton\n                    type=\"button\"\n                    class=\"btn btn-info\"\n                    click={importUsers}\n                >\n                    Import users\n                </AsyncButton>\n                <div class=\"me-auto\"></div>\n                <AsyncButton type=\"button\" class=\"btn btn-primary\" click={save}>\n                    Save\n                </AsyncButton>\n                <AsyncButton type=\"button\" class=\"btn btn-danger\" click={remove}>\n                    Remove\n                </AsyncButton>\n            </div>\n        </form>\n    </div>\n</Loadable>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/ldap/LdapServers.svelte",
    "content": "<script lang=\"ts\">\n    import { Observable, from, map } from 'rxjs'\n    import { api } from 'admin/lib/api'\n    import ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'\n    import { link } from 'svelte-spa-router'\n    import EmptyState from 'common/EmptyState.svelte'\n    import InfoBox from 'common/InfoBox.svelte'\n    import { adminPermissions } from 'admin/lib/store'\n    import PermissionGate from 'admin/lib/PermissionGate.svelte'\n\n    interface LdapServer {\n        id: string\n        name: string\n        host: string\n        port: number\n        enabled: boolean\n        description: string\n    }\n\n    function getLdapServers (options: LoadOptions): Observable<PaginatedResponse<LdapServer>> {\n        return from(api.getLdapServers({\n            search: options.search,\n        })).pipe(map(servers => ({\n            items: servers,\n            offset: 0,\n            total: servers.length,\n        })))\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <div class=\"page-summary-bar\">\n        <h1>LDAP Servers</h1>\n        <a\n            class=\"btn btn-primary ms-auto\"\n            href=\"/config/ldap-servers/create\"\n            class:disabled={!$adminPermissions.configEdit}\n            use:link>\n            Add LDAP Server\n        </a>\n    </div>\n\n    <InfoBox>\n        Currently, LDAP can only be used to import users and their SSH keys. Warpgate will automatically sync the public keys of Warpgate users that are linked to LDAP users.\n    </InfoBox>\n\n    <PermissionGate perm=\"configEdit\" message=\"You have no permission to manage LDAP servers.\">\n        <ItemList load={getLdapServers} showSearch={true}>\n            {#snippet empty()}\n                <EmptyState\n                    title=\"No LDAP servers configured\"\n                    hint=\"Connecting to LDAP lets you synchronize users' SSH keys from it\"\n                />\n            {/snippet}\n            {#snippet item(server)}\n                <a\n                    class=\"list-group-item list-group-item-action\"\n                    href=\"/config/ldap-servers/{server.id}\"\n                    use:link>\n                    <strong class=\"me-auto\">\n                        {server.name}\n                    </strong>\n                    {#if server.description}\n                        <small class=\"d-block text-muted\">{server.description}</small>\n                    {/if}\n                </a>\n            {/snippet}\n        </ItemList>\n    </PermissionGate>\n</div>\n\n<style lang=\"scss\">\n    .list-group-item {\n        display: block;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/ldap/LdapUserBrowser.svelte",
    "content": "<script lang=\"ts\">\n    import { api, stringifyError } from 'admin/lib/api'\n    import Loadable from 'common/Loadable.svelte'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Fa from 'svelte-fa'\n    import { faRefresh } from '@fortawesome/free-solid-svg-icons'\n\n    interface Props {\n        params: { id: string }\n    }\n\n    let { params }: Props = $props()\n\n    let server = $state<any>(null)\n    let users = $state<any[]>([])\n    let error = $state<string | null>(null)\n    let success = $state<string | null>(null)\n    let searchTerm = $state('')\n\n    let selectedUserDns = $state<string[]>([])\n\n    async function load() {\n        server = await api.getLdapServer({ id: params.id })\n        await loadUsers()\n    }\n\n    async function loadUsers() {\n        error = null\n        try {\n            users = await api.getLdapUsers({ id: params.id })\n        } catch (e: any) {\n            error = await stringifyError(e)\n        }\n    }\n\n    let filteredUsers = $derived(\n        searchTerm\n            ? users.filter(\n                (u) =>\n                    u.username.toLowerCase().includes(searchTerm.toLowerCase()) ||\n                    u.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||\n                    u.displayName?.toLowerCase().includes(searchTerm.toLowerCase()))\n            : users,\n    )\n\n    async function batchImport () {\n        error = null\n        success = null\n        try {\n            await api.importLdapUsers({\n                id: params.id,\n                importLdapUsersRequest: {\n                    dns: selectedUserDns,\n                },\n            })\n            await loadUsers()\n            success = `Successfully imported ${selectedUserDns.length} users.`\n            selectedUserDns = []\n        } catch (e: any) {\n            error = await stringifyError(e)\n        }\n    }\n</script>\n\n\n{#if error}\n    <Alert color=\"danger\">{error}</Alert>\n{/if}\n{#if success}\n    <Alert color=\"success\">{success}</Alert>\n{/if}\n\n<Loadable promise={load()}>\n    {#if server}\n    <div class=\"container-max-md\">\n        <div class=\"page-summary-bar\">\n            <h1>{server.name}</h1>\n        </div>\n\n        {#if users.length === 0}\n            <div class=\"text-center my-5\">\n                <AsyncButton class=\"btn btn-primary\" click={loadUsers}>\n                    Load Users from LDAP\n                </AsyncButton>\n            </div>\n        {:else}\n            <div class=\"mb-3\">\n                <input\n                    type=\"text\"\n                    class=\"form-control\"\n                    placeholder=\"Search users...\"\n                    bind:value={searchTerm}\n                />\n            </div>\n\n            <div class=\"d-flex justify-content-between align-items-center mb-2\">\n                <span class=\"text-muted\">\n                    {filteredUsers.length} users {searchTerm ? `(filtered from ${users.length})` : ''}\n                </span>\n                <div class=\"d-flex gap-2\">\n                    <AsyncButton\n                        class=\"btn btn-sm btn-primary\"\n                        click={batchImport}\n                        disabled={selectedUserDns.length === 0}\n                    >\n                        Import {selectedUserDns.length} selected\n                    </AsyncButton>\n                    <AsyncButton class=\"btn btn-sm btn-secondary\" click={loadUsers}>\n                        <Fa icon={faRefresh} />\n                    </AsyncButton>\n                </div>\n            </div>\n\n            <div class=\"list-group\">\n                {#each filteredUsers as user (user.dn)}\n                    <div class=\"list-group-item d-flex align-items-center gap-3\">\n                        <input\n                            type=\"checkbox\"\n                            class=\"form-check-input\"\n                            bind:group={selectedUserDns}\n                            value={user.dn}\n                            aria-label=\"Select user\"\n                        />\n                        <div class=\"flex-grow-1\">\n                            <div>\n                                <h6 class=\"mb-1\">\n                                    {user.username}\n                                    {#if user.displayName && user.displayName !== user.username}\n                                        <small class=\"text-muted ms-1\">({user.displayName})</small>\n                                    {/if}\n                                </h6>\n                            </div>\n                            <small class=\"text-muted\">DN: {user.dn}</small>\n                        </div>\n                    </div>\n                {/each}\n            </div>\n        {/if}\n    </div>\n    {/if}\n</Loadable>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/ldap/common.ts",
    "content": "import { api, TlsMode, type TestLdapServerRequest, type TestLdapServerResponse } from 'admin/lib/api'\n\nexport async function testLdapConnection(options: TestLdapServerRequest): Promise<TestLdapServerResponse> {\n    const timeoutPromise = new Promise((_, reject) => {\n        setTimeout(() => reject(new Error('Connection test timed out after 10 seconds')), 10000)\n    })\n\n    const testPromise = api.testLdapServerConnection({\n        testLdapServerRequest: options,\n    })\n\n    return (await Promise.race([testPromise, timeoutPromise])) as TestLdapServerResponse\n}\n\nexport function defaultLdapPortForTlsMode(tlsMode: TlsMode): number {\n    if (tlsMode === TlsMode.Disabled) {\n        return 389\n    } else {\n        return 636\n    }\n}\n"
  },
  {
    "path": "warpgate-web/src/admin/config/target-groups/CreateTargetGroup.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type BootstrapThemeColor } from 'admin/lib/api'\n    import { link, replace } from 'svelte-spa-router'\n    import { FormGroup, Input, Label } from '@sveltestrap/sveltestrap'\n    import { stringifyError } from 'common/errors'\n    import GroupColorCircle from 'common/GroupColorCircle.svelte'\n    import { VALID_CHOICES } from './common'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n\n    let name = $state('')\n    let description = $state('')\n    let color = $state<BootstrapThemeColor | ''>('')\n    let error: string | undefined = $state()\n\n    async function save () {\n        if (!name.trim()) {\n            error = 'Name is required'\n            return\n        }\n\n        error = undefined\n\n        try {\n            await api.createTargetGroup({\n                targetGroupDataRequest: {\n                    name: name.trim(),\n                    description: description.trim() || undefined,\n                    color: color || undefined,\n                },\n            })\n            // Redirect to groups list\n            replace('/config/target-groups')\n        } catch (e) {\n            error = await stringifyError(e)\n            throw e\n        }\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <div class=\"page-summary-bar\">\n        <h1>add a target group</h1>\n    </div>\n\n    {#if error}\n        <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    <form onsubmit={e => {\n        e.preventDefault()\n        save()\n    }}>\n        <FormGroup>\n            <Label for=\"name\">Name</Label>\n            <Input\n                id=\"name\"\n                bind:value={name}\n                required\n            />\n        </FormGroup>\n\n        <FormGroup>\n            <Label for=\"description\">Description</Label>\n            <Input\n                id=\"description\"\n                type=\"textarea\"\n                bind:value={description}\n            />\n        </FormGroup>\n\n        <FormGroup>\n            <Label for=\"color\">Color</Label>\n            <small class=\"form-text text-muted\">\n                Optional theme color for visual organization\n            </small>\n            <div class=\"color-picker\">\n                {#each VALID_CHOICES as value (value)}\n                    <button\n                        type=\"button\"\n                        class=\"btn btn-secondary\"\n                        class:active={color === value}\n                        onclick={e => {\n                            e.preventDefault()\n                            color = value\n                        }}\n                        title={value || 'None'}\n                    >\n                        <GroupColorCircle color={value} />\n                        <span>{value || 'None'}</span>\n                    </button>\n                {/each}\n            </div>\n        </FormGroup>\n\n        <div class=\"d-flex gap-2 mt-5\">\n            <AsyncButton click={save} color=\"primary\">Create</AsyncButton>\n            <a class=\"btn btn-secondary\" href=\"/config/target-groups\" use:link>\n                Cancel\n            </a>\n        </div>\n    </form>\n</div>\n\n<style lang=\"scss\">\n    .color-picker {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 0.5rem;\n\n        > button {\n            display: flex;\n            align-items: center;\n            gap: 0.5rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/target-groups/TargetGroup.svelte",
    "content": "<script lang=\"ts\">\n    import { api, BootstrapThemeColor, type TargetGroup } from 'admin/lib/api'\n    import { Button, FormGroup, Input, Label } from '@sveltestrap/sveltestrap'\n    import { stringifyError } from 'common/errors'\n    import { VALID_CHOICES } from './common'\n    import GroupColorCircle from 'common/GroupColorCircle.svelte'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import Loadable from 'common/Loadable.svelte'\n    import { replace } from 'svelte-spa-router'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import { adminPermissions } from 'admin/lib/store'\n\n    interface Props {\n        params: { id: string };\n    }\n\n    let { params }: Props = $props()\n    let groupId = $derived(params.id)\n\n    let group: TargetGroup | undefined = $state()\n    let error: string | undefined = $state()\n    let saving = $state(false)\n\n    let name = $state('')\n    let description = $state('')\n    let color = $state<BootstrapThemeColor | ''>('')\n\n    const initPromise = init()\n\n    async function init () {\n        try {\n            group = await api.getTargetGroup({ id: groupId })\n            name = group.name\n            description = group.description\n            color = group.color ?? ''\n        } catch (e) {\n            error = await stringifyError(e)\n            throw e\n        }\n    }\n\n    async function update () {\n        if (!group) {\n            return\n        }\n\n        saving = true\n        error = undefined\n\n        try {\n            await api.updateTargetGroup({\n                id: groupId,\n                targetGroupDataRequest: {\n                    name,\n                    description: description || undefined,\n                    color: color || undefined,\n                },\n            })\n        } catch (e) {\n            error = await stringifyError(e)\n            throw e\n        } finally {\n            saving = false\n        }\n    }\n\n    async function remove () {\n        if (!group || !confirm(`Delete target group \"${group.name}\"?`)) {\n            return\n        }\n\n        try {\n            await api.deleteTargetGroup({ id: groupId })\n            // Redirect to groups list\n            replace('/config/target-groups')\n        } catch (e) {\n            error = await stringifyError(e)\n            throw e\n        }\n    }\n</script>\n\n\n{#if error}\n    <Alert color=\"danger\">{error}</Alert>\n{/if}\n<Loadable promise={initPromise}>\n{#if group}\n    <div class=\"container-max-md\">\n        <div class=\"page-summary-bar\">\n            <div>\n                <h1>{group.name}</h1>\n                <div class=\"text-muted\">Target group</div>\n            </div>\n        </div>\n\n        <form onsubmit={e => {\n            e.preventDefault()\n            update()\n        }}>\n            <FormGroup>\n                <Label for=\"name\">Name</Label>\n                <Input\n                    id=\"name\"\n                    bind:value={name}\n                    required\n                    disabled={saving}\n                />\n            </FormGroup>\n\n            <FormGroup>\n                <Label for=\"description\">Description</Label>\n                <Input\n                    id=\"description\"\n                    type=\"textarea\"\n                    bind:value={description}\n                    disabled={saving}\n                />\n            </FormGroup>\n\n            <FormGroup>\n                <Label for=\"color\">Color</Label>\n                <small class=\"form-text text-muted\">\n                    Optional Bootstrap theme color for visual organization\n                </small>\n                <div class=\"color-picker\">\n                    {#each VALID_CHOICES as value (value)}\n                        <button\n                            type=\"button\"\n                            class=\"btn btn-secondary gap-2 d-flex align-items-center\"\n                            class:active={color === value}\n                            disabled={saving}\n                            onclick={(e) => {\n                                e.preventDefault()\n                                color = value\n                            }}\n                            title={value || 'None'}\n                        >\n                            <GroupColorCircle color={value} />\n                            <span>{value || 'None'}</span>\n                        </button>\n                    {/each}\n                </div>\n            </FormGroup>\n\n            <div class=\"d-flex gap-2 mt-5\">\n                <AsyncButton\n                    click={update}\n                    color=\"primary\"\n                    disabled={!$adminPermissions.targetsEdit}\n                >Update</AsyncButton>\n                <Button\n                    color=\"danger\"\n                    onclick={remove}\n                    disabled={!$adminPermissions.targetsDelete}\n                >Remove</Button>\n            </div>\n        </form>\n    </div>\n{/if}\n</Loadable>\n\n<style lang=\"scss\">\n    .color-picker {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 0.5rem;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/target-groups/TargetGroups.svelte",
    "content": "<script lang=\"ts\">\n    import { Observable, from, map } from 'rxjs'\n    import { type TargetGroup, api } from 'admin/lib/api'\n    import ItemList, { type PaginatedResponse } from 'common/ItemList.svelte'\n    import { link } from 'svelte-spa-router'\n    import EmptyState from 'common/EmptyState.svelte'\n    import GroupColorCircle from 'common/GroupColorCircle.svelte'\n    import { compare as naturalCompareFactory } from 'natural-orderby'\n    import { adminPermissions } from 'admin/lib/store'\n\n    function getTargetGroups(): Observable<PaginatedResponse<TargetGroup>> {\n        return from(api.listTargetGroups()).pipe(\n            map(groups => {\n                const sorted = groups.sort((a, b) =>\n                    naturalCompareFactory()(\n                        a.name.toLowerCase(),\n                        b.name.toLowerCase()\n                    )\n                )\n\n                return {\n                    items: sorted,\n                    offset: 0,\n                    total: sorted.length,\n                }\n            })\n        )\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <div class=\"page-summary-bar\">\n        <h1>target groups</h1>\n        <a\n            class=\"btn btn-primary ms-auto\"\n            href=\"/config/target-groups/create\"\n            class:disabled={!$adminPermissions.targetsCreate}\n            use:link>\n            Add a group\n        </a>\n    </div>\n\n    <ItemList load={getTargetGroups} showSearch={true}>\n        {#snippet empty()}\n            <EmptyState\n                title=\"No target groups yet\"\n                hint=\"Target groups help organize your targets for easier management\"\n            />\n        {/snippet}\n        {#snippet item(group)}\n            <a\n                class=\"list-group-item list-group-item-action\"\n                href=\"/config/target-groups/{group.id}\"\n                use:link>\n                <div class=\"me-auto\">\n                    <div class=\"d-flex align-items-center gap-2\">\n                        {#if group.color}\n                            <GroupColorCircle color={group.color} />\n                        {/if}\n                        <strong>{group.name}</strong>\n                    </div>\n                    {#if group.description}\n                        <small class=\"d-block text-muted\">{group.description}</small>\n                    {/if}\n                </div>\n            </a>\n        {/snippet}\n    </ItemList>\n</div>\n\n<style lang=\"scss\">\n    .list-group-item {\n        display: flex;\n        align-items: center;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/target-groups/common.ts",
    "content": "import type { BootstrapThemeColor } from 'gateway/lib/api'\n\nexport const VALID_COLORS: BootstrapThemeColor[] = ['Primary', 'Secondary', 'Success', 'Danger', 'Warning', 'Info', 'Light', 'Dark']\nexport const VALID_CHOICES = ['' as (BootstrapThemeColor | ''), ...VALID_COLORS]\n"
  },
  {
    "path": "warpgate-web/src/admin/config/targets/ChooseTargetKind.svelte",
    "content": "<script lang=\"ts\">\n    import { TargetKind } from 'gateway/lib/api'\n    import NavListItem from 'common/NavListItem.svelte'\n    import Badge from 'common/sveltestrap-s5-ports/Badge.svelte'\n\n    const kinds: {\n        name: string,\n        value: TargetKind,\n        description: string,\n        experimental?: boolean\n    }[] = [\n        {\n            name: 'SSH',\n            value: TargetKind.Ssh,\n            description: 'Expose access to shell, SFTP and port forwarding',\n        },\n        {\n            name: 'HTTP',\n            value: TargetKind.Http,\n            description: 'Warpgate will act as a reverse proxy',\n        },\n        {\n            name: 'MySQL',\n            value: TargetKind.MySql,\n            description: 'Expose access to a database server',\n        },\n        {\n            name: 'PostgreSQL',\n            value: TargetKind.Postgres,\n            description: 'Expose access to a database server',\n        },\n        {\n            name: 'Kubernetes',\n            value: TargetKind.Kubernetes,\n            description: 'Expose Kubernetes API protocol for tools like kubectl',\n            experimental: true,\n        },\n    ]\n</script>\n\n<div class=\"container-max-md\">\n    <div class=\"page-summary-bar\">\n        <h1>add a target</h1>\n    </div>\n\n    <div class=\"narrow-page\">\n        {#each kinds as kind (kind.value)}\n            <NavListItem\n                title={kind.name}\n                description={kind.description}\n                href={`/config/targets/create/${kind.value}`}\n            >\n                {#snippet addonSnippet()}\n                    {#if kind.experimental}\n                        <Badge color=\"warning\">Experimental</Badge>\n                    {/if}\n                {/snippet}\n            </NavListItem>\n        {/each}\n    </div>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/targets/CreateTarget.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type TargetOptions, type TargetGroup, TlsMode } from 'admin/lib/api'\n    import { replace } from 'svelte-spa-router'\n    import { Button, Form, FormGroup } from '@sveltestrap/sveltestrap'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import { onMount } from 'svelte'\n    import { TargetKind } from 'gateway/lib/api'\n    import { adminPermissions } from '../../lib/store'\n\n    interface Props {\n        params: { kind: string }\n    }\n\n    let { params }: Props = $props()\n\n    let error: string|null = $state(null)\n    let name = $state('')\n    let groups: TargetGroup[] = $state([])\n    let selectedGroupId: string | undefined = $state()\n\n    async function create () {\n        try {\n            const options: TargetOptions|undefined = {\n                Ssh: {\n                    kind: TargetKind.Ssh,\n                    host: '192.168.0.1',\n                    port: 22,\n                    username: 'root',\n                    auth: {\n                        kind: 'PublicKey' as const,\n                    },\n                },\n                Http: {\n                    kind: TargetKind.Http,\n                    url: 'http://192.168.0.1',\n                    tls: {\n                        mode: TlsMode.Preferred,\n                        verify: true,\n                    },\n                },\n                MySql: {\n                    kind: TargetKind.MySql,\n                    host: '192.168.0.1',\n                    port: 3306,\n                    tls: {\n                        mode: TlsMode.Preferred,\n                        verify: true,\n                    },\n                    username: 'root',\n                    password: '',\n                },\n                Postgres: {\n                    kind: TargetKind.Postgres,\n                    host: '192.168.0.1',\n                    port: 5432,\n                    tls: {\n                        mode: TlsMode.Preferred,\n                        verify: true,\n                    },\n                    username: 'postgres',\n                    password: '',\n                },\n                Kubernetes: {\n                    kind: TargetKind.Kubernetes,\n                    clusterUrl: 'https://kubernetes.example.com:6443',\n                    tls: {\n                        mode: TlsMode.Preferred,\n                        verify: true,\n                    },\n                    auth: {\n                        kind: 'Certificate' as const,\n                        certificate: '',\n                        privateKey: '',\n                    },\n                },\n            }[params.kind]\n            if (!options) {\n                return\n            }\n            const target = await api.createTarget({\n                targetDataRequest: {\n                    name,\n                    options,\n                    groupId: selectedGroupId,\n                },\n            })\n            replace(`/config/targets/${target.id}`)\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    }\n\n    onMount(async () => {\n        try {\n            groups = await api.listTargetGroups()\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    })\n</script>\n\n<div class=\"container-max-md\">\n    {#if !$adminPermissions.targetsCreate}\n        <Alert color=\"warning\">You do not have permission to create targets.</Alert>\n    {/if}\n    {#if error}\n    <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    <div class=\"page-summary-bar\">\n        <h1>add a target</h1>\n    </div>\n\n    <div class=\"narrow-page\">\n        <Form on:submit={e => {\n            create()\n            e.preventDefault()\n        }}>\n            <!-- Defualt button for key handling -->\n            <Button class=\"d-none\" type=\"submit\"></Button>\n\n            <FormGroup floating label=\"Name\">\n                <!-- svelte-ignore a11y_autofocus -->\n                <input class=\"form-control\" autofocus required bind:value={name} />\n            </FormGroup>\n\n            {#if groups.length > 0}\n            <FormGroup floating label=\"Group\">\n                <select class=\"form-control\" bind:value={selectedGroupId}>\n                    <option value={undefined}>No group</option>\n                    {#each groups as group (group.id)}\n                        <option value={group.id}>{group.name}</option>\n                    {/each}\n                </select>\n            </FormGroup>\n            {/if}\n\n            <Button\n                color=\"primary\"\n                type=\"submit\"\n            >Create target</Button>\n        </Form>\n    </div>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/targets/Target.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type Role, type Target, type TargetGroup } from 'admin/lib/api'\n    import { adminPermissions } from '../../lib/store'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import ConnectionInstructions from 'common/ConnectionInstructions.svelte'\n    import { TargetKind } from 'gateway/lib/api'\n    import { serverInfo } from 'gateway/lib/store'\n    import { replace } from 'svelte-spa-router'\n    import { Button, FormGroup, Input, Modal, ModalBody, ModalFooter } from '@sveltestrap/sveltestrap'\n    import TlsConfiguration from '../../TlsConfiguration.svelte'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Loadable from 'common/Loadable.svelte'\n    import ModalHeader from 'common/sveltestrap-s5-ports/ModalHeader.svelte'\n    import TargetSshOptions from './ssh/Options.svelte'\n    import RateLimitInput from 'common/RateLimitInput.svelte'\n\n    interface Props {\n        params: { id: string };\n    }\n\n    let { params }: Props = $props()\n\n    let error: string|undefined = $state()\n    let selectedUsername: string|undefined = $state($serverInfo?.username)\n    let target: Target | undefined = $state()\n    let roleIsAllowed: Record<string, any> = $state({})\n    let connectionsInstructionsModalOpen = $state(false)\n    let groups: TargetGroup[] = $state([])\n\n    async function init () {\n        [target, groups] = await Promise.all([\n            api.getTarget({ id: params.id }),\n            api.listTargetGroups(),\n        ])\n    }\n\n    async function loadRoles () {\n        const allRoles = await api.getRoles()\n        const allowedRoles = await api.getTargetRoles(target!)\n        roleIsAllowed = Object.fromEntries(allowedRoles.map(r => [r.id, true]))\n        return allRoles\n    }\n\n    async function update () {\n        try {\n            if (target!.options.kind === 'Http') {\n                target!.options.externalHost = target!.options.externalHost || undefined\n            }\n            target = await api.updateTarget({\n                id: params.id,\n                targetDataRequest: target!,\n            })\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    }\n\n    async function remove () {\n        if (confirm(`Delete target ${target!.name}?`)) {\n            await api.deleteTarget(target!)\n            replace('/config/targets')\n        }\n    }\n\n    async function toggleRole (role: Role) {\n        if (roleIsAllowed[role.id]) {\n            await api.deleteTargetRole({\n                id: target!.id,\n                roleId: role.id,\n            })\n            roleIsAllowed = { ...roleIsAllowed, [role.id]: false }\n        } else {\n            await api.addTargetRole({\n                id: target!.id,\n                roleId: role.id,\n            })\n            roleIsAllowed = { ...roleIsAllowed, [role.id]: true }\n        }\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <Loadable promise={init()}>\n    {#if target}\n        <Modal isOpen={connectionsInstructionsModalOpen} toggle={() => connectionsInstructionsModalOpen = false}>\n            <ModalHeader>\n                Access instructions\n            </ModalHeader>\n            <ModalBody>\n                {#if target.options.kind === 'Ssh' || target.options.kind === 'MySql' || target.options.kind === 'Postgres' || target.options.kind === 'Kubernetes'}\n                    <Loadable promise={api.getUsers()}>\n                        {#snippet children(users)}\n                            <FormGroup floating label=\"Select a user\">\n                                <select bind:value={selectedUsername} class=\"form-control\">\n                                    {#each users as user (user.id)}\n                                        <option value={user.username}>\n                                            {user.username}\n                                        </option>\n                                    {/each}\n                                </select>\n                            </FormGroup>\n                        {/snippet}\n                    </Loadable>\n                {/if}\n\n                {#key connectionsInstructionsModalOpen} <!-- regenerate examples when modal opens -->\n                    <ConnectionInstructions\n                        targetName={target.name}\n                        username={selectedUsername}\n                        targetKind={target.options.kind}\n                        targetExternalHost={target.options.kind === TargetKind.Http ? target.options.externalHost : undefined}\n                        targetDefaultDatabaseName={\n                            (target.options.kind === TargetKind.MySql || target.options.kind === TargetKind.Postgres)\n                                ? target.options.defaultDatabaseName : undefined}\n                    />\n                {/key}\n            </ModalBody>\n            <ModalFooter>\n                <Button\n                    color=\"secondary\"\n                    class=\"modal-button\"\n                    block\n                    on:click={() => connectionsInstructionsModalOpen = false }\n                >\n                    Close\n                </Button>\n            </ModalFooter>\n        </Modal>\n\n        <div class=\"page-summary-bar\">\n            <div>\n                <h1>{target.name}</h1>\n                <div class=\"text-muted\">\n                    {#if target.options.kind === 'MySql'}\n                        MySQL target\n                    {/if}\n                    {#if target.options.kind === 'Postgres'}\n                        PostgreSQL target\n                    {/if}\n                    {#if target.options.kind === 'Ssh'}\n                        SSH target\n                    {/if}\n                    {#if target.options.kind === 'Http'}\n                        HTTP target\n                    {/if}\n                    {#if target.options.kind === 'Kubernetes'}\n                        Kubernetes target\n                    {/if}\n                </div>\n            </div>\n        </div>\n\n        <h4 class=\"mt-4\">Configuration</h4>\n\n        <div class=\"row\">\n            <div class:col-md-8={groups.length > 0} class:col-md-12={!groups.length}>\n                <FormGroup floating label=\"Name\">\n                    <Input class=\"form-control\" bind:value={target.name} />\n                </FormGroup>\n            </div>\n\n            {#if groups.length > 0}\n            <div class=\"col-md-4\">\n                <FormGroup floating label=\"Group\">\n                    <select class=\"form-control\" bind:value={target.groupId}>\n                        <option value={undefined}>No group</option>\n                        {#each groups as group (group.id)}\n                            <option value={group.id}>{group.name}</option>\n                        {/each}\n                    </select>\n                </FormGroup>\n            </div>\n            {/if}\n        </div>\n\n        <FormGroup floating label=\"Description\">\n            <Input bind:value={target.description} />\n        </FormGroup>\n\n        {#if target.options.kind === 'Ssh'}\n            <TargetSshOptions id={target.id} options={target.options} />\n        {/if}\n\n        {#if target.options.kind === 'Http'}\n            <FormGroup floating label=\"Target URL\">\n                <input class=\"form-control\" bind:value={target.options.url} />\n            </FormGroup>\n\n            <TlsConfiguration bind:value={target.options.tls} />\n\n            {#if $serverInfo?.externalHost}\n                <FormGroup floating label=\"Bind to a domain\">\n                    <Input type=\"text\" placeholder={'foo.' + $serverInfo.externalHost} bind:value={target.options.externalHost} />\n                </FormGroup>\n            {/if}\n        {/if}\n\n        {#if target.options.kind === 'MySql' || target.options.kind === 'Postgres'}\n            <div class=\"row\">\n                <div class=\"col-8\">\n                    <FormGroup floating label=\"Target host\">\n                        <input class=\"form-control\" bind:value={target.options.host} />\n                    </FormGroup>\n                </div>\n                <div class=\"col-4\">\n                    <FormGroup floating label=\"Target port\">\n                        <input class=\"form-control\" type=\"number\" bind:value={target.options.port} min=\"1\" max=\"65535\" step=\"1\" />\n                    </FormGroup>\n                </div>\n            </div>\n\n            <div class=\"row\">\n                <div class=\"col\">\n                    <FormGroup floating label=\"Username\">\n                        <input class=\"form-control\" bind:value={target.options.username} />\n                    </FormGroup>\n                </div>\n                <div class=\"col\">\n                    <FormGroup floating label=\"Password\">\n                        <input class=\"form-control\" type=\"password\" autocomplete=\"off\" bind:value={target.options.password} />\n                    </FormGroup>\n                </div>\n            </div>\n\n            <TlsConfiguration bind:value={target.options.tls} />\n        {/if}\n\n        {#if target.options.kind === 'Kubernetes'}\n            <FormGroup floating label=\"Cluster URL\">\n                <input class=\"form-control\" bind:value={target.options.clusterUrl} placeholder=\"https://kubernetes.example.com:6443\" />\n            </FormGroup>\n\n            <h5 class=\"mt-3\">Authentication</h5>\n            <FormGroup floating label=\"Auth Type\">\n                <select class=\"form-control\" bind:value={target.options.auth.kind}>\n                    <option value=\"Certificate\">Certificate</option>\n                    <option value=\"Token\">Token</option>\n                </select>\n            </FormGroup>\n\n            {#if target.options.auth.kind === 'Certificate'}\n                <FormGroup floating label=\"Client Certificate\">\n                    <textarea class=\"form-control\" style=\"height: 18rem;\" bind:value={target.options.auth.certificate} placeholder=\"-----BEGIN CERTIFICATE-----\"></textarea>\n                </FormGroup>\n                <FormGroup floating label=\"Client Private Key\">\n                    <textarea class=\"form-control\" style=\"height: 12rem;\" bind:value={target.options.auth.privateKey} placeholder=\"-----BEGIN RSA PRIVATE KEY-----\"></textarea>\n                </FormGroup>\n            {/if}\n\n            {#if target.options.auth.kind === 'Token'}\n                <FormGroup floating label=\"Bearer Token\">\n                    <input class=\"form-control\" type=\"password\" autocomplete=\"off\" bind:value={target.options.auth.token} />\n                </FormGroup>\n            {/if}\n\n            <TlsConfiguration bind:value={target.options.tls} />\n        {/if}\n\n        <h4 class=\"mt-4\">Allow access for roles</h4>\n        <Loadable promise={loadRoles()}>\n            {#snippet children(roles)}\n                <div class=\"list-group list-group-flush mb-3\">\n                    {#each roles as role (role.id)}\n                        <label\n                            for=\"role-{role.id}\"\n                            class=\"list-group-item list-group-item-action d-flex align-items-center\"\n                        >\n                            <Input\n                                id=\"role-{role.id}\"\n                                class=\"mb-0 me-2\"\n                                type=\"switch\"\n                                disabled={!$adminPermissions.targetsEdit}\n                                on:change={() => toggleRole(role)}\n                                checked={roleIsAllowed[role.id]} />\n                            <div>\n                                <div>{role.name}</div>\n                                {#if role.description}\n                                    <small class=\"text-muted\">{role.description}</small>\n                                {/if}\n                            </div>\n                        </label>\n                    {/each}\n                </div>\n            {/snippet}\n        </Loadable>\n\n        <h4 class=\"mt-4\">Advanced</h4>\n        {#if target.options.kind === 'Postgres'}\n            <FormGroup floating label=\"Idle timeout\">\n                <input\n                    class=\"form-control\"\n                    type=\"text\"\n                    placeholder=\"10m\"\n                    bind:value={target.options.idleTimeout}\n                    title=\"Human-readable duration (e.g., '30m', '1h', '2h30m'). Default: 10m\"\n                />\n                <small class=\"form-text text-muted\">\n                    How long an authenticated session can remain idle before requiring re-authentication. Examples: 30m, 1h, 2h30m. Leave empty for default (10m).\n                </small>\n            </FormGroup>\n        {/if}\n\n        {#if target.options.kind === 'MySql' || target.options.kind === 'Postgres'}\n            <FormGroup floating label=\"Default database name for connection examples\">\n                <input\n                    class=\"form-control\"\n                    type=\"text\"\n                    placeholder=\"database-name\"\n                    bind:value={target.options.defaultDatabaseName}\n                />\n                <small class=\"form-text text-muted\">\n                    Default database name used in connection examples. This is only for display purposes and does not restrict which databases users can access. Leave empty to use the global default.\n                </small>\n            </FormGroup>\n        {/if}\n\n        <FormGroup>\n            <label for=\"rateLimitBytesPerSecond\">Global bandwidth limit</label>\n            <RateLimitInput\n                id=\"rateLimitBytesPerSecond\"\n                bind:value={target.rateLimitBytesPerSecond}\n            />\n        </FormGroup>\n\n        <div class=\"mb-5\"></div>\n    {/if}\n    </Loadable>\n\n    {#if error}\n        <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    <div class=\"d-flex\">\n        <Button\n            color=\"secondary\"\n            class=\"me-3\"\n            on:click={() => connectionsInstructionsModalOpen = true}\n        >Access instructions</Button>\n\n        <AsyncButton\n            color=\"primary\"\n            class=\"ms-auto\"\n            click={update}\n            disabled={!$adminPermissions.targetsEdit}\n        >Update configuration</AsyncButton>\n\n        <AsyncButton\n            class=\"ms-2\"\n            color=\"danger\"\n            click={remove}\n            disabled={!$adminPermissions.targetsDelete}\n        >Remove</AsyncButton>\n    </div>\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/targets/Targets.svelte",
    "content": "<script lang=\"ts\">\n    import { Observable, from, map } from 'rxjs'\n    import { compare as naturalCompareFactory } from 'natural-orderby'\n    import { type Target, type TargetGroup, api } from 'admin/lib/api'\n    import { adminPermissions } from '../../lib/store'\n    import ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'\n    import { link } from 'svelte-spa-router'\n    import { TargetKind } from 'gateway/lib/api'\n    import EmptyState from 'common/EmptyState.svelte'\n    import { onMount } from 'svelte'\n    import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from '@sveltestrap/sveltestrap'\n    import GroupColorCircle from 'common/GroupColorCircle.svelte'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import { firstBy } from 'thenby'\n\n    let error: string|undefined = $state()\n    let groups: TargetGroup[] = $state([])\n    let selectedGroup: TargetGroup|undefined = $state()\n\n    onMount(async () => {\n        try {\n            const natural = naturalCompareFactory()\n\n            groups = (await api.listTargetGroups()).sort((a, b) =>\n                natural(\n                    a.name.toLowerCase(),\n                    b.name.toLowerCase()\n                )\n            )\n        } catch (err) {\n            error = await stringifyError(err)\n        }\n    })\n\n    function getTargets(\n        options: LoadOptions\n    ): Observable<PaginatedResponse<Target>> {\n        return from(\n            api.getTargets({\n                search: options.search,\n                groupId: selectedGroup?.id,\n            })\n        ).pipe(\n            map(targets => {\n                if (options.search) {\n                    return targets\n                }\n\n                const natural = naturalCompareFactory()\n\n                return targets.sort(\n                    firstBy((x: Target) => !x.groupId)\n                        // Natural sort between groups\n                        .thenBy(\n                            (a: Target, b: Target) =>\n                                natural(\n                                    (groups.find(g => g.id === a.groupId)?.name ?? '').toLowerCase(),\n                                    (groups.find(g => g.id === b.groupId)?.name ?? '').toLowerCase()\n                                )\n                        )\n                        // Natural sort within a group\n                        .thenBy(\n                            (a, b) =>\n                                natural(\n                                    a.name.toLowerCase(),\n                                    b.name.toLowerCase()\n                                )\n                        )\n                )\n            }),\n            map(targets => ({\n                items: targets,\n                offset: 0,\n                total: targets.length,\n            }))\n        )\n    }\n</script>\n\n<div class=\"container-max-md\">\n    <div class=\"page-summary-bar\">\n        <h1>targets</h1>\n        <div class=\"d-flex gap-2 ms-auto\">\n            {#if groups.length > 0}\n            <Dropdown>\n                <DropdownToggle caret>\n                    {selectedGroup?.name ?? 'All groups'}\n                </DropdownToggle>\n                <DropdownMenu>\n                    <DropdownItem onclick={() => {\n                        selectedGroup = undefined\n                    }}>\n                        All groups\n                    </DropdownItem>\n                    {#each groups as group (group.id)}\n                        <DropdownItem onclick={() => {\n                            selectedGroup = group\n                        }} class=\"d-flex align-items-center gap-2\">\n                            {#if group.color}\n                                <GroupColorCircle color={group.color} />\n                            {/if}\n                            {group.name}\n                        </DropdownItem>\n                    {/each}\n                </DropdownMenu>\n            </Dropdown>\n            {/if}\n            <a\n                class=\"btn btn-primary\"\n                href=\"/config/targets/create\"\n                class:disabled={!$adminPermissions.targetsCreate}\n                use:link>\n                Add a target\n            </a>\n        </div>\n    </div>\n\n    {#if error}\n        <Alert color=\"danger\">{error}</Alert>\n    {/if}\n\n    {#key selectedGroup}\n    <ItemList load={getTargets} showSearch={true}>\n        {#snippet empty()}\n            <EmptyState\n                title=\"No targets yet\"\n                hint=\"Targets are destinations on the internal network that your users will connect to\"\n            />\n        {/snippet}\n        {#snippet item(target)}\n            <a\n                class=\"list-group-item list-group-item-action\"\n                href=\"/config/targets/{target.id}\"\n                use:link>\n                <div class=\"me-auto\">\n                    <div class=\"d-flex align-items-center gap-2\">\n                        {#if target.groupId}\n                            {@const group = groups.find(g => g.id === target.groupId)}\n                            {#if group}\n                                {#if group.color}\n                                    <GroupColorCircle color={group.color} />\n                                {/if}\n                                <small class=\"text-muted\">{group.name}</small>\n                            {/if}\n                        {/if}\n                        <strong>\n                            {target.name}\n                        </strong>\n                    </div>\n                    {#if target.description}\n                        <small class=\"d-block text-muted\">{target.description}</small>\n                    {/if}\n                </div>\n                <small class=\"text-muted ms-auto\">\n                    {#if target.options.kind === TargetKind.Http}\n                        HTTP\n                    {/if}\n                    {#if target.options.kind === TargetKind.MySql}\n                        MySQL\n                    {/if}\n                    {#if target.options.kind === TargetKind.Postgres}\n                        PostgreSQL\n                    {/if}\n                    {#if target.options.kind === TargetKind.Ssh}\n                        SSH\n                    {/if}\n                    {#if target.options.kind === TargetKind.Kubernetes}\n                        Kubernetes\n                    {/if}\n                </small>\n            </a>\n        {/snippet}\n    </ItemList>\n    {/key}\n</div>\n\n<style lang=\"scss\">\n    .list-group-item {\n        display: flex;\n        align-items: center;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/targets/ssh/KeyChecker.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type CheckSshHostKeyResponseBody, type SSHKnownHost, type TargetOptionsTargetSSHOptions } from '../../../lib/api'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import KeyCheckerResult, { Key, type CheckResult } from './KeyCheckerResult.svelte'\n    import { stringifyError } from 'common/errors'\n\n    type State = {\n        state: 'initializing'\n    } | {\n        state: 'not-checked',\n        hasTrustedKeys: boolean\n    } | {\n        state: 'checking',\n        previousResult: CheckResult | null\n    } | {\n        state: 'ready',\n        result: CheckResult\n    } | {\n        state: 'error',\n        error: string\n    }\n\n    let _state: State = $state({\n        state: 'initializing',\n    })\n\n    interface Props {\n        id: string,\n        options: TargetOptionsTargetSSHOptions;\n    }\n\n    let { id, options }: Props = $props()\n    let knownHosts: SSHKnownHost[] | null = $state(null)\n    let remoteHostKey: CheckSshHostKeyResponseBody | null = $state(null)\n\n    $effect(() => {\n        (async function () {\n            // eslint-disable-next-line @typescript-eslint/no-unused-expressions\n            options // run effect when options get reassigned after saving, since changes in host/ip invalidate the currenty loaded known hosts\n            _state = { state: 'initializing' }\n            await reloadKnownHosts()\n            updateReadyState()\n        })()\n    })\n\n    function updateReadyState () {\n        if (!remoteHostKey) {\n            if (knownHosts === null) {\n                _state = { state: 'initializing' }\n                return\n            } else {\n                _state = { state: 'not-checked', hasTrustedKeys: knownHosts.length > 0 }\n                return\n            }\n        }\n\n        _state = { state: 'ready', result: getCheckResult(knownHosts, remoteHostKey) }\n    }\n\n    function getCheckResult (knownHosts: SSHKnownHost[] | null, remoteHostKey: CheckSshHostKeyResponseBody): CheckResult {\n        const actualKey = new Key(remoteHostKey.remoteKeyType, remoteHostKey.remoteKeyBase64)\n\n        if (knownHosts?.some(k => k.keyType === remoteHostKey!.remoteKeyType && k.keyBase64 === remoteHostKey!.remoteKeyBase64)) {\n            return  {\n                state: 'key-valid',\n            }\n        } else {\n            return knownHosts?.length ? {\n                state: 'key-invalid',\n                actualKey,\n                trustedKeys: knownHosts.map(k => new Key(k.keyType, k.keyBase64)),\n            } : {\n                state: 'key-unknown',\n                actualKey,\n            }\n        }\n    }\n\n    async function reloadKnownHosts () {\n        try {\n            knownHosts = await api.getSshTargetKnownSshHostKeys({ id })\n        } catch (err) {\n            _state = { state: 'error', error: await stringifyError(err) }\n            throw err\n        }\n    }\n\n    async function checkRemoteHostKey () {\n        if (!options.host || !options.port) {\n            return\n        }\n        _state = { state: 'checking', previousResult: _state.state === 'ready' ? _state.result : null }\n        try {\n            remoteHostKey = await api.checkSshHostKey({\n                checkSshHostKeyRequest: options,\n            })\n        } catch (err) {\n            _state = { state: 'error', error: await stringifyError(err) }\n            return\n        }\n        updateReadyState()\n    }\n\n    async function trustRemoteKey () {\n        try {\n            await api.addSshKnownHost({\n                addSshKnownHostRequest: {\n                    host: options.host,\n                    port: options.port,\n                    keyBase64: remoteHostKey!.remoteKeyBase64,\n                    keyType: remoteHostKey!.remoteKeyType,\n                },\n            })\n        } catch (err) {\n            _state = { state: 'error', error: await stringifyError(err) }\n            throw err\n        }\n        await reloadKnownHosts()\n        updateReadyState()\n    }\n</script>\n\n<div class=\"d-flex host-key-status\">\n    {#if _state.state === 'initializing'}\n    <Alert color=\"secondary\">\n        Looking for trusted keys...\n    </Alert>\n    {/if}\n\n    {#if _state.state === 'not-checked'}\n    <Alert color=\"secondary\">\n        {#if _state.hasTrustedKeys}\n            There is a saved trusted key\n        {:else}\n            There are no trusted host keys yet\n        {/if}\n    </Alert>\n    {/if}\n\n    {#if _state.state === 'checking'}\n        {#if _state.previousResult}\n            <KeyCheckerResult result={_state.previousResult} />\n        {:else}\n            <Alert color=\"secondary\">\n                Retrieving remote host key...\n            </Alert>\n        {/if}\n    {/if}\n\n    {#if _state.state === 'ready'}\n        <KeyCheckerResult result={_state.result} />\n    {/if}\n\n    {#if _state.state === 'error'}\n        <Alert color=\"danger\">{_state.error}</Alert>\n    {/if}\n\n    <div class=\"buttons\">\n        {#if _state.state === 'ready' && _state.result.state === 'key-unknown'}\n            <AsyncButton color=\"primary\" click={trustRemoteKey}>\n                Trust\n            </AsyncButton>\n        {/if}\n\n        {#if _state.state === 'ready' && _state.result.state === 'key-invalid'}\n            <AsyncButton color=\"danger\" click={trustRemoteKey}>\n                Trust the new key\n            </AsyncButton>\n        {/if}\n\n        <AsyncButton color=\"secondary\" click={checkRemoteHostKey}>\n            {#if _state.state === 'ready' || _state.state === 'checking' && _state.previousResult}\n                Recheck\n            {:else}\n                Check host key\n            {/if}\n        </AsyncButton>\n    </div>\n</div>\n\n<style lang=\"scss\">\n    .host-key-status {\n        .buttons {\n            display: flex;\n            flex-direction: column;\n            flex-wrap: wrap;\n            align-items: stretch;\n            gap: 0.5rem;\n            flex-shrink: 0;\n            margin-left: 0.5rem;\n\n            :global(.btn) {\n                flex: 1 0 auto;\n            }\n        }\n    }\n\n    :global(.alert) {\n        min-width: 0;\n        flex-grow: 1;\n        padding-top: 0.5rem;\n        padding-bottom: 0.5rem;\n        margin-bottom: 0;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/targets/ssh/KeyCheckerResult.svelte",
    "content": "<script lang=\"ts\" module>\n    export class Key {\n        constructor (public type: string, public publicKeyBase64: string) { }\n\n        toString (): string {\n            return this.type + ' ' + this.publicKeyBase64\n        }\n    }\n\n    export type CheckResult = {\n        state: 'key-valid',\n    } | {\n        state: 'key-invalid',\n        trustedKeys: Key[],\n        actualKey: Key\n    } | {\n        state: 'key-unknown',\n        actualKey: Key,\n    }\n\n</script>\n<script lang=\"ts\">\n    import { faCheck, faWarning } from '@fortawesome/free-solid-svg-icons'\n    import CopyButton from 'common/CopyButton.svelte'\n\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Fa from 'svelte-fa'\n\n    interface Props {\n        result: CheckResult,\n    }\n\n    let { result }: Props = $props()\n</script>\n\n\n{#if result.state === 'key-valid'}\n<Alert color=\"success\" class=\"d-flex align-items-center\">\n    <Fa icon={faCheck} class=\"me-3\" />\n    Remote host key is trusted\n</Alert>\n{/if}\n{#if result.state === 'key-unknown'}\n<Alert color=\"warning\">\n    <div>Remote host key is not trusted yet</div>\n    <pre class=\"key-value\">{result.actualKey} <CopyButton link text={result.actualKey.toString()} class=\"copy-button\" /></pre>\n</Alert>\n{/if}\n\n{#if result.state === 'key-invalid'}\n<Alert color=\"danger\" class=\"d-flex align-items-center\">\n    <Fa icon={faWarning} class=\"me-3\" />\n    <div style=\"min-width: 0\">\n        <h5>Remote host key has changed!</h5>\n        {#if result.trustedKeys.length}\n            <strong>Known trusted keys:</strong>\n            <div class=\"mb-2\">\n                {#each result.trustedKeys as key (key)}\n                    <pre class=\"key-value\">{key} <CopyButton link text={key.toString()} class=\"copy-button\" /></pre>\n                {/each}\n            </div>\n        {/if}\n        <strong>Current remote key:</strong>\n        <pre class=\"key-value\">{result.actualKey} <CopyButton link text={result.actualKey.toString()} class=\"copy-button\" /></pre>\n    </div>\n</Alert>\n{/if}\n\n<style lang=\"scss\">\n    .key-value {\n        word-wrap: break-word;\n        margin-bottom: 0;\n        white-space: break-spaces;\n\n        background: rgba(0, 0, 0, .5);\n        border-radius: 3px;\n        padding: 5px 10px;\n\n        :global(.copy-button) {\n            float: right;\n            margin-left: 0.5rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/admin/config/targets/ssh/Options.svelte",
    "content": "<script lang=\"ts\">\n    import { FormGroup, Input } from '@sveltestrap/sveltestrap'\n    import { type TargetOptionsTargetSSHOptions } from '../../../lib/api'\n    import { faExternalLink } from '@fortawesome/free-solid-svg-icons'\n    import Fa from 'svelte-fa'\n    import TargetSshHostKeyChecker from './KeyChecker.svelte'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import { adminPermissions } from 'admin/lib/store'\n\n    interface Props {\n        id: string,\n        options: TargetOptionsTargetSSHOptions,\n    }\n\n    let { id, options }: Props = $props()\n\n    let hostKeyCheckInvalidated = $state(false)\n\n    $effect(() => {\n        // eslint-disable-next-line @typescript-eslint/no-unused-expressions\n        options // run effect when options get reassigned after saving\n        hostKeyCheckInvalidated = false\n    })\n</script>\n\n<h4 class=\"mt-4\">Connection</h4>\n\n<div class=\"row\">\n    <div class=\"col-8\">\n        <FormGroup floating label=\"Target host\">\n            <input class=\"form-control\" bind:value={options.host} onchange={() => hostKeyCheckInvalidated = true} />\n        </FormGroup>\n    </div>\n    <div class=\"col-4\">\n        <FormGroup floating label=\"Target port\">\n            <input class=\"form-control\" type=\"number\" bind:value={options.port} min=\"1\" max=\"65535\" step=\"1\" onchange={() => hostKeyCheckInvalidated = true} />\n        </FormGroup>\n    </div>\n</div>\n\n<h4 class=\"mt-4\">Authentication</h4>\n\n{#if $adminPermissions.targetsEdit}\n<div class=\"mb-3\">\n    {#if !hostKeyCheckInvalidated}\n    <TargetSshHostKeyChecker id={id} options={options} />\n    {:else}\n    <Alert color=\"secondary\">Save changes to see the host key validation status</Alert>\n    {/if}\n</div>\n{/if}\n\n<FormGroup floating label=\"Username\">\n    <input class=\"form-control\"\n        placeholder=\"Use the currently logged in user's name\"\n        bind:value={options.username}\n    />\n</FormGroup>\n\n<div class=\"d-flex\">\n    <FormGroup floating label=\"Authenticate using\" class=\"w-100\">\n        <select bind:value={options.auth.kind} class=\"form-control\">\n            <option value=\"PublicKey\">Warpgate's own private keys</option>\n            <option value=\"Password\">Password</option>\n        </select>\n    </FormGroup>\n    {#if options.auth.kind === 'PublicKey'}\n        <a\n            class=\"btn btn-link mb-3 d-flex align-items-center\"\n            href=\"/@warpgate/admin#/config/ssh\"\n            target=\"_blank\">\n            <Fa fw icon={faExternalLink} />\n        </a>\n    {/if}\n    {#if options.auth.kind === 'Password'}\n        <FormGroup floating label=\"Password\" class=\"w-100 ms-3\">\n            <input class=\"form-control\" type=\"password\" autocomplete=\"off\" bind:value={options.auth.password} />\n        </FormGroup>\n    {/if}\n</div>\n\n<div class=\"d-flex\">\n    <Input\n        class=\"mb-0 me-2\"\n        type=\"switch\"\n        label=\"Allow insecure SSH algorithms (e.g. for older network devices)\"\n        bind:checked={options.allowInsecureAlgos} />\n</div>\n"
  },
  {
    "path": "warpgate-web/src/admin/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" href=\"/@warpgate/assets/favicon.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Warpgate</title>\n    <style>#app { display: none }</style>\n</head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/admin/index.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "warpgate-web/src/admin/index.ts",
    "content": "import { mount } from 'svelte'\nimport '../theme'\nimport App from './App.svelte'\n\nmount(App, {\n    target: document.getElementById('app')!,\n})\n\nexport { }\n"
  },
  {
    "path": "warpgate-web/src/admin/lib/PermissionGate.svelte",
    "content": "<script lang=\"ts\">\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import { adminPermissions, type AdminPermissionKey } from './store'\n\n    /**\n     * Render children only if the given admin permission is granted.\n     * If not, the content placed in the `fallback` slot will be shown\n     * instead (defaults to nothing).\n     */\n    export let perm: AdminPermissionKey\n\n    export let message: string | undefined\n</script>\n\n{#if $adminPermissions[perm]}\n    <slot />\n{:else}\n    {#if message}\n        <Alert color=\"warning\">\n            {message}\n        </Alert>\n    {/if}\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/admin/lib/api.ts",
    "content": "import { DefaultApi, Configuration, ResponseError } from './api-client/dist'\n\nconst configuration = new Configuration({\n    basePath: '/@warpgate/admin/api',\n})\n\nexport const api = new DefaultApi(configuration)\nexport * from './api-client'\n\nexport async function stringifyError (err: ResponseError): Promise<string> {\n    return `API error: ${await err.response.text()}`\n}\n"
  },
  {
    "path": "warpgate-web/src/admin/lib/openapi-schema.json",
    "content": "{\n  \"openapi\": \"3.0.0\",\n  \"info\": {\n    \"title\": \"Warpgate Web Admin\",\n    \"version\": \"v0.21.1-5-ga76ef2bb-modified\"\n  },\n  \"servers\": [\n    {\n      \"url\": \"/@warpgate/admin/api\"\n    }\n  ],\n  \"tags\": [],\n  \"paths\": {\n    \"/sessions\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"offset\",\n            \"schema\": {\n              \"type\": \"integer\",\n              \"format\": \"uint64\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"limit\",\n            \"schema\": {\n              \"type\": \"integer\",\n              \"format\": \"uint64\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"active_only\",\n            \"schema\": {\n              \"type\": \"boolean\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"logged_in_only\",\n            \"schema\": {\n              \"type\": \"boolean\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/PaginatedResponse_SessionSnapshot\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_sessions\"\n      },\n      \"delete\": {\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"close_all_sessions\"\n      }\n    },\n    \"/sessions/{id}\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SessionSnapshot\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_session\"\n      }\n    },\n    \"/sessions/{id}/recordings\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/Recording\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_session_recordings\"\n      }\n    },\n    \"/sessions/{id}/close\": {\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"close_session\"\n      }\n    },\n    \"/recordings/{id}\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Recording\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_recording\"\n      }\n    },\n    \"/recordings/{id}/kubernetes\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/KubernetesRecordingItem\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_kubernetes_recording\"\n      }\n    },\n    \"/roles\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"search\",\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/Role\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_roles\"\n      },\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/RoleDataRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Role\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_role\"\n      }\n    },\n    \"/role/{id}\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Role\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_role\"\n      },\n      \"put\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/RoleDataRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Role\"\n                }\n              }\n            }\n          },\n          \"403\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_role\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"403\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_role\"\n      }\n    },\n    \"/role/{id}/targets\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/Target\"\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_role_targets\"\n      }\n    },\n    \"/role/{id}/users\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/User\"\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_role_users\"\n      }\n    },\n    \"/admin-roles\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"search\",\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/AdminRole\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_admin_roles\"\n      },\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/AdminRoleDataRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/AdminRole\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_admin_role\"\n      }\n    },\n    \"/admin-roles/{id}\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/AdminRole\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_admin_role\"\n      },\n      \"put\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/AdminRoleDataRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/AdminRole\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_admin_role\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"403\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_admin_role\"\n      }\n    },\n    \"/admin-roles/{id}/users\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/User\"\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_admin_role_users\"\n      }\n    },\n    \"/tickets\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/Ticket\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_tickets\"\n      },\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/CreateTicketRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/TicketAndSecret\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_ticket\"\n      }\n    },\n    \"/tickets/{id}\": {\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_ticket\"\n      }\n    },\n    \"/ssh/known-hosts\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/AddSshKnownHostRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/SSHKnownHost\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"add_ssh_known_host\"\n      },\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/SSHKnownHost\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_ssh_known_hosts\"\n      }\n    },\n    \"/ssh/known-hosts/{id}\": {\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_ssh_known_host\"\n      }\n    },\n    \"/ssh/own-keys\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/SSHKey\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_ssh_own_keys\"\n      }\n    },\n    \"/logs\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/GetLogsRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/LogEntry\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_logs\"\n      }\n    },\n    \"/targets\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"search\",\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"group_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/Target\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_targets\"\n      },\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/TargetDataRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Target\"\n                }\n              }\n            }\n          },\n          \"409\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_target\"\n      }\n    },\n    \"/targets/{id}\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Target\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_target\"\n      },\n      \"put\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/TargetDataRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Target\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_target\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_target\"\n      }\n    },\n    \"/targets/{id}/known-ssh-host-keys\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/SSHKnownHost\"\n                  }\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_ssh_target_known_ssh_host_keys\"\n      }\n    },\n    \"/targets/{id}/roles\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/Role\"\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_target_roles\"\n      }\n    },\n    \"/targets/{id}/roles/{role_id}\": {\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"role_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\"\n          },\n          \"409\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"add_target_role\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"role_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_target_role\"\n      }\n    },\n    \"/target-groups\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/TargetGroup\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"list_target_groups\"\n      },\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/TargetGroupDataRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/TargetGroup\"\n                }\n              }\n            }\n          },\n          \"409\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_target_group\"\n      }\n    },\n    \"/target-groups/{id}\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/TargetGroup\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_target_group\"\n      },\n      \"put\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/TargetGroupDataRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/TargetGroup\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_target_group\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_target_group\"\n      }\n    },\n    \"/users\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"search\",\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/User\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_users\"\n      },\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/CreateUserRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/User\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_user\"\n      }\n    },\n    \"/users/{id}\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/User\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_user\"\n      },\n      \"put\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/UserDataRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/User\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_user\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_user\"\n      }\n    },\n    \"/users/{id}/ldap-link/unlink\": {\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/User\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"unlink_user_from_ldap\"\n      }\n    },\n    \"/users/{id}/ldap-link/auto-link\": {\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/User\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"auto_link_user_to_ldap\"\n      }\n    },\n    \"/users/{id}/roles\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/Role\"\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_user_roles\"\n      }\n    },\n    \"/users/{id}/roles/{role_id}\": {\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"role_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\"\n          },\n          \"409\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"add_user_role\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"role_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_user_role\"\n      }\n    },\n    \"/users/{id}/admin-roles\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/AdminRole\"\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_user_admin_roles\"\n      }\n    },\n    \"/users/{id}/admin-roles/{role_id}\": {\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"role_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\"\n          },\n          \"409\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"add_user_admin_role\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"role_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_user_admin_role\"\n      }\n    },\n    \"/users/{user_id}/credentials/passwords\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/ExistingPasswordCredential\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_password_credentials\"\n      },\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/NewPasswordCredential\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ExistingPasswordCredential\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_password_credential\"\n      }\n    },\n    \"/users/{user_id}/credentials/passwords/{id}\": {\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_password_credential\"\n      }\n    },\n    \"/users/{user_id}/credentials/sso\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/ExistingSsoCredential\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_sso_credentials\"\n      },\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/NewSsoCredential\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ExistingSsoCredential\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_sso_credential\"\n      }\n    },\n    \"/users/{user_id}/credentials/sso/{id}\": {\n      \"put\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/NewSsoCredential\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ExistingSsoCredential\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_sso_credential\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_sso_credential\"\n      }\n    },\n    \"/users/{user_id}/credentials/public-keys\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/ExistingPublicKeyCredential\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_public_key_credentials\"\n      },\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/NewPublicKeyCredential\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ExistingPublicKeyCredential\"\n                }\n              }\n            }\n          },\n          \"403\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_public_key_credential\"\n      }\n    },\n    \"/users/{user_id}/credentials/public-keys/{id}\": {\n      \"put\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/NewPublicKeyCredential\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ExistingPublicKeyCredential\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          },\n          \"403\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_public_key_credential\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          },\n          \"403\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_public_key_credential\"\n      }\n    },\n    \"/users/{user_id}/credentials/otp\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/ExistingOtpCredential\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_otp_credentials\"\n      },\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/NewOtpCredential\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ExistingOtpCredential\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_otp_credential\"\n      }\n    },\n    \"/users/{user_id}/credentials/otp/{id}\": {\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_otp_credential\"\n      }\n    },\n    \"/ldap-servers\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"search\",\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/LdapServerResponse\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_ldap_servers\"\n      },\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/CreateLdapServerRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/LdapServerResponse\"\n                }\n              }\n            }\n          },\n          \"409\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"create_ldap_server\"\n      }\n    },\n    \"/ldap-servers/test\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/TestLdapServerRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/TestLdapServerResponse\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"test_ldap_server_connection\"\n      }\n    },\n    \"/ldap-servers/{id}\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/LdapServerResponse\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_ldap_server\"\n      },\n      \"put\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/UpdateLdapServerRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/LdapServerResponse\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_ldap_server\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_ldap_server\"\n      }\n    },\n    \"/ldap-servers/{id}/users\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/LdapUserResponse\"\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          },\n          \"400\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_ldap_users\"\n      }\n    },\n    \"/ldap-servers/{id}/import-users\": {\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/ImportLdapUsersRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"type\": \"string\"\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"import_ldap_users\"\n      }\n    },\n    \"/parameters\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ParameterValues\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_parameters\"\n      },\n      \"put\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/ParameterUpdate\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_parameters\"\n      }\n    },\n    \"/ssh/check-host-key\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/CheckSshHostKeyRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/CheckSshHostKeyResponseBody\"\n                }\n              }\n            }\n          },\n          \"500\": {\n            \"description\": \"\",\n            \"content\": {\n              \"text/plain; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"check_ssh_host_key\"\n      }\n    },\n    \"/users/{user_id}/credentials/certificates\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/ExistingCertificateCredential\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_certificate_credentials\"\n      },\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/IssueCertificateCredentialRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/IssuedCertificateCredential\"\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"issue_certificate_credential\"\n      }\n    },\n    \"/users/{user_id}/credentials/certificates/{id}\": {\n      \"patch\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/UpdateCertificateCredential\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ExistingCertificateCredential\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"update_certificate_credential\"\n      },\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"user_id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"revoke_certificate_credential\"\n      }\n    }\n  },\n  \"components\": {\n    \"schemas\": {\n      \"AddSshKnownHostRequest\": {\n        \"type\": \"object\",\n        \"title\": \"AddSshKnownHostRequest\",\n        \"required\": [\n          \"host\",\n          \"port\",\n          \"key_type\",\n          \"key_base64\"\n        ],\n        \"properties\": {\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"int32\"\n          },\n          \"key_type\": {\n            \"type\": \"string\"\n          },\n          \"key_base64\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"AdminRole\": {\n        \"type\": \"object\",\n        \"title\": \"AdminRole\",\n        \"required\": [\n          \"id\",\n          \"name\",\n          \"description\",\n          \"targets_create\",\n          \"targets_edit\",\n          \"targets_delete\",\n          \"users_create\",\n          \"users_edit\",\n          \"users_delete\",\n          \"access_roles_create\",\n          \"access_roles_edit\",\n          \"access_roles_delete\",\n          \"access_roles_assign\",\n          \"sessions_view\",\n          \"sessions_terminate\",\n          \"recordings_view\",\n          \"tickets_create\",\n          \"tickets_delete\",\n          \"config_edit\",\n          \"admin_roles_manage\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"targets_create\": {\n            \"type\": \"boolean\"\n          },\n          \"targets_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"targets_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"users_create\": {\n            \"type\": \"boolean\"\n          },\n          \"users_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"users_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_create\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_assign\": {\n            \"type\": \"boolean\"\n          },\n          \"sessions_view\": {\n            \"type\": \"boolean\"\n          },\n          \"sessions_terminate\": {\n            \"type\": \"boolean\"\n          },\n          \"recordings_view\": {\n            \"type\": \"boolean\"\n          },\n          \"tickets_create\": {\n            \"type\": \"boolean\"\n          },\n          \"tickets_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"config_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"admin_roles_manage\": {\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"AdminRoleDataRequest\": {\n        \"type\": \"object\",\n        \"title\": \"AdminRoleDataRequest\",\n        \"required\": [\n          \"name\",\n          \"targets_create\",\n          \"targets_edit\",\n          \"targets_delete\",\n          \"users_create\",\n          \"users_edit\",\n          \"users_delete\",\n          \"access_roles_create\",\n          \"access_roles_edit\",\n          \"access_roles_delete\",\n          \"access_roles_assign\",\n          \"sessions_view\",\n          \"sessions_terminate\",\n          \"recordings_view\",\n          \"tickets_create\",\n          \"tickets_delete\",\n          \"config_edit\",\n          \"admin_roles_manage\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"targets_create\": {\n            \"type\": \"boolean\"\n          },\n          \"targets_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"targets_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"users_create\": {\n            \"type\": \"boolean\"\n          },\n          \"users_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"users_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_create\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_assign\": {\n            \"type\": \"boolean\"\n          },\n          \"sessions_view\": {\n            \"type\": \"boolean\"\n          },\n          \"sessions_terminate\": {\n            \"type\": \"boolean\"\n          },\n          \"recordings_view\": {\n            \"type\": \"boolean\"\n          },\n          \"tickets_create\": {\n            \"type\": \"boolean\"\n          },\n          \"tickets_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"config_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"admin_roles_manage\": {\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"BootstrapThemeColor\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Primary\",\n          \"Secondary\",\n          \"Success\",\n          \"Danger\",\n          \"Warning\",\n          \"Info\",\n          \"Light\",\n          \"Dark\"\n        ]\n      },\n      \"CheckSshHostKeyRequest\": {\n        \"type\": \"object\",\n        \"title\": \"CheckSshHostKeyRequest\",\n        \"required\": [\n          \"host\",\n          \"port\"\n        ],\n        \"properties\": {\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          }\n        }\n      },\n      \"CheckSshHostKeyResponseBody\": {\n        \"type\": \"object\",\n        \"title\": \"CheckSshHostKeyResponseBody\",\n        \"required\": [\n          \"remote_key_type\",\n          \"remote_key_base64\"\n        ],\n        \"properties\": {\n          \"remote_key_type\": {\n            \"type\": \"string\"\n          },\n          \"remote_key_base64\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"CreateLdapServerRequest\": {\n        \"type\": \"object\",\n        \"title\": \"CreateLdapServerRequest\",\n        \"required\": [\n          \"name\",\n          \"host\",\n          \"bind_dn\",\n          \"bind_password\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"int32\",\n            \"default\": 389\n          },\n          \"bind_dn\": {\n            \"type\": \"string\"\n          },\n          \"bind_password\": {\n            \"type\": \"string\"\n          },\n          \"user_filter\": {\n            \"type\": \"string\",\n            \"default\": \"(objectClass=person)\"\n          },\n          \"tls_mode\": {\n            \"default\": \"Preferred\",\n            \"allOf\": [\n              {\n                \"$ref\": \"#/components/schemas/TlsMode\"\n              },\n              {\n                \"default\": \"Preferred\"\n              }\n            ]\n          },\n          \"tls_verify\": {\n            \"type\": \"boolean\",\n            \"default\": true\n          },\n          \"enabled\": {\n            \"type\": \"boolean\",\n            \"default\": true\n          },\n          \"auto_link_sso_users\": {\n            \"type\": \"boolean\",\n            \"default\": false\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"username_attribute\": {\n            \"default\": \"Cn\",\n            \"allOf\": [\n              {\n                \"$ref\": \"#/components/schemas/LdapUsernameAttribute\"\n              },\n              {\n                \"default\": \"Cn\"\n              }\n            ]\n          },\n          \"ssh_key_attribute\": {\n            \"type\": \"string\",\n            \"default\": \"sshPublicKey\"\n          },\n          \"uuid_attribute\": {\n            \"type\": \"string\",\n            \"default\": \"\"\n          }\n        }\n      },\n      \"CreateTicketRequest\": {\n        \"type\": \"object\",\n        \"title\": \"CreateTicketRequest\",\n        \"required\": [\n          \"username\",\n          \"target_name\"\n        ],\n        \"properties\": {\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"target_name\": {\n            \"type\": \"string\"\n          },\n          \"expiry\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"number_of_uses\": {\n            \"type\": \"integer\",\n            \"format\": \"int16\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"CreateUserRequest\": {\n        \"type\": \"object\",\n        \"title\": \"CreateUserRequest\",\n        \"required\": [\n          \"username\"\n        ],\n        \"properties\": {\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"CredentialKind\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Password\",\n          \"PublicKey\",\n          \"Certificate\",\n          \"Totp\",\n          \"Sso\",\n          \"WebUserApproval\"\n        ]\n      },\n      \"ExistingCertificateCredential\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingCertificateCredential\",\n        \"required\": [\n          \"id\",\n          \"label\",\n          \"fingerprint\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"date_added\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"last_used\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"fingerprint\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"ExistingOtpCredential\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingOtpCredential\",\n        \"required\": [\n          \"id\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          }\n        }\n      },\n      \"ExistingPasswordCredential\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingPasswordCredential\",\n        \"required\": [\n          \"id\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          }\n        }\n      },\n      \"ExistingPublicKeyCredential\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingPublicKeyCredential\",\n        \"required\": [\n          \"id\",\n          \"label\",\n          \"openssh_public_key\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"date_added\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"last_used\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"openssh_public_key\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"ExistingSsoCredential\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingSsoCredential\",\n        \"required\": [\n          \"id\",\n          \"email\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"provider\": {\n            \"type\": \"string\"\n          },\n          \"email\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"GetLogsRequest\": {\n        \"type\": \"object\",\n        \"title\": \"GetLogsRequest\",\n        \"properties\": {\n          \"before\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"after\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"limit\": {\n            \"type\": \"integer\",\n            \"format\": \"uint64\"\n          },\n          \"session_id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"search\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"ImportLdapUsersRequest\": {\n        \"type\": \"object\",\n        \"title\": \"ImportLdapUsersRequest\",\n        \"required\": [\n          \"dns\"\n        ],\n        \"properties\": {\n          \"dns\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          }\n        }\n      },\n      \"IssueCertificateCredentialRequest\": {\n        \"type\": \"object\",\n        \"title\": \"IssueCertificateCredentialRequest\",\n        \"required\": [\n          \"label\",\n          \"public_key_pem\"\n        ],\n        \"properties\": {\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"public_key_pem\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"IssuedCertificateCredential\": {\n        \"type\": \"object\",\n        \"title\": \"IssuedCertificateCredential\",\n        \"required\": [\n          \"credential\",\n          \"certificate_pem\"\n        ],\n        \"properties\": {\n          \"credential\": {\n            \"$ref\": \"#/components/schemas/ExistingCertificateCredential\"\n          },\n          \"certificate_pem\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"KubernetesRecordingItem\": {\n        \"type\": \"object\",\n        \"title\": \"KubernetesRecordingItem\",\n        \"required\": [\n          \"timestamp\",\n          \"request_method\",\n          \"request_path\",\n          \"request_body\",\n          \"response_body\"\n        ],\n        \"properties\": {\n          \"timestamp\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"request_method\": {\n            \"type\": \"string\"\n          },\n          \"request_path\": {\n            \"type\": \"string\"\n          },\n          \"request_body\": {},\n          \"response_status\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          },\n          \"response_body\": {}\n        }\n      },\n      \"KubernetesTargetAuth\": {\n        \"type\": \"object\",\n        \"oneOf\": [\n          {\n            \"$ref\": \"#/components/schemas/KubernetesTargetAuth_KubernetesTargetTokenAuth\"\n          },\n          {\n            \"$ref\": \"#/components/schemas/KubernetesTargetAuth_KubernetesTargetCertificateAuth\"\n          }\n        ],\n        \"discriminator\": {\n          \"propertyName\": \"kind\",\n          \"mapping\": {\n            \"Token\": \"#/components/schemas/KubernetesTargetAuth_KubernetesTargetTokenAuth\",\n            \"Certificate\": \"#/components/schemas/KubernetesTargetAuth_KubernetesTargetCertificateAuth\"\n          }\n        }\n      },\n      \"KubernetesTargetAuth_KubernetesTargetCertificateAuth\": {\n        \"allOf\": [\n          {\n            \"type\": \"object\",\n            \"required\": [\n              \"kind\"\n            ],\n            \"properties\": {\n              \"kind\": {\n                \"type\": \"string\",\n                \"enum\": [\n                  \"Certificate\"\n                ],\n                \"example\": \"Certificate\"\n              }\n            }\n          },\n          {\n            \"$ref\": \"#/components/schemas/KubernetesTargetCertificateAuth\"\n          }\n        ]\n      },\n      \"KubernetesTargetAuth_KubernetesTargetTokenAuth\": {\n        \"allOf\": [\n          {\n            \"type\": \"object\",\n            \"required\": [\n              \"kind\"\n            ],\n            \"properties\": {\n              \"kind\": {\n                \"type\": \"string\",\n                \"enum\": [\n                  \"Token\"\n                ],\n                \"example\": \"Token\"\n              }\n            }\n          },\n          {\n            \"$ref\": \"#/components/schemas/KubernetesTargetTokenAuth\"\n          }\n        ]\n      },\n      \"KubernetesTargetCertificateAuth\": {\n        \"type\": \"object\",\n        \"title\": \"KubernetesTargetCertificateAuth\",\n        \"required\": [\n          \"certificate\",\n          \"private_key\"\n        ],\n        \"properties\": {\n          \"certificate\": {\n            \"type\": \"string\"\n          },\n          \"private_key\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"KubernetesTargetTokenAuth\": {\n        \"type\": \"object\",\n        \"title\": \"KubernetesTargetTokenAuth\",\n        \"required\": [\n          \"token\"\n        ],\n        \"properties\": {\n          \"token\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"LdapServerResponse\": {\n        \"type\": \"object\",\n        \"title\": \"LdapServerResponse\",\n        \"required\": [\n          \"id\",\n          \"name\",\n          \"host\",\n          \"port\",\n          \"bind_dn\",\n          \"user_filter\",\n          \"base_dns\",\n          \"tls_mode\",\n          \"tls_verify\",\n          \"enabled\",\n          \"auto_link_sso_users\",\n          \"description\",\n          \"username_attribute\",\n          \"ssh_key_attribute\",\n          \"uuid_attribute\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"int32\"\n          },\n          \"bind_dn\": {\n            \"type\": \"string\"\n          },\n          \"user_filter\": {\n            \"type\": \"string\"\n          },\n          \"base_dns\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          },\n          \"tls_mode\": {\n            \"$ref\": \"#/components/schemas/TlsMode\"\n          },\n          \"tls_verify\": {\n            \"type\": \"boolean\"\n          },\n          \"enabled\": {\n            \"type\": \"boolean\"\n          },\n          \"auto_link_sso_users\": {\n            \"type\": \"boolean\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"username_attribute\": {\n            \"$ref\": \"#/components/schemas/LdapUsernameAttribute\"\n          },\n          \"ssh_key_attribute\": {\n            \"type\": \"string\"\n          },\n          \"uuid_attribute\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"LdapUserResponse\": {\n        \"type\": \"object\",\n        \"title\": \"LdapUserResponse\",\n        \"required\": [\n          \"username\",\n          \"dn\"\n        ],\n        \"properties\": {\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"email\": {\n            \"type\": \"string\"\n          },\n          \"display_name\": {\n            \"type\": \"string\"\n          },\n          \"dn\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"LdapUsernameAttribute\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Cn\",\n          \"Uid\",\n          \"Email\",\n          \"UserPrincipalName\",\n          \"SamAccountName\"\n        ]\n      },\n      \"LogEntry\": {\n        \"type\": \"object\",\n        \"title\": \"LogEntry\",\n        \"required\": [\n          \"id\",\n          \"text\",\n          \"values\",\n          \"timestamp\",\n          \"session_id\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"text\": {\n            \"type\": \"string\"\n          },\n          \"values\": {},\n          \"timestamp\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"session_id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"username\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"NewOtpCredential\": {\n        \"type\": \"object\",\n        \"title\": \"NewOtpCredential\",\n        \"required\": [\n          \"secret_key\"\n        ],\n        \"properties\": {\n          \"secret_key\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"integer\",\n              \"format\": \"uint8\"\n            }\n          }\n        }\n      },\n      \"NewPasswordCredential\": {\n        \"type\": \"object\",\n        \"title\": \"NewPasswordCredential\",\n        \"required\": [\n          \"password\"\n        ],\n        \"properties\": {\n          \"password\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"NewPublicKeyCredential\": {\n        \"type\": \"object\",\n        \"title\": \"NewPublicKeyCredential\",\n        \"required\": [\n          \"label\",\n          \"openssh_public_key\"\n        ],\n        \"properties\": {\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"openssh_public_key\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"NewSsoCredential\": {\n        \"type\": \"object\",\n        \"title\": \"NewSsoCredential\",\n        \"required\": [\n          \"email\"\n        ],\n        \"properties\": {\n          \"provider\": {\n            \"type\": \"string\"\n          },\n          \"email\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"PaginatedResponse_SessionSnapshot\": {\n        \"type\": \"object\",\n        \"title\": \"PaginatedResponse_SessionSnapshot\",\n        \"required\": [\n          \"items\",\n          \"offset\",\n          \"total\"\n        ],\n        \"properties\": {\n          \"items\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/SessionSnapshot\"\n            }\n          },\n          \"offset\": {\n            \"type\": \"integer\",\n            \"format\": \"uint64\"\n          },\n          \"total\": {\n            \"type\": \"integer\",\n            \"format\": \"uint64\"\n          }\n        }\n      },\n      \"ParameterUpdate\": {\n        \"type\": \"object\",\n        \"title\": \"ParameterUpdate\",\n        \"required\": [\n          \"allow_own_credential_management\"\n        ],\n        \"properties\": {\n          \"allow_own_credential_management\": {\n            \"type\": \"boolean\"\n          },\n          \"rate_limit_bytes_per_second\": {\n            \"type\": \"integer\",\n            \"format\": \"uint32\"\n          },\n          \"ssh_client_auth_publickey\": {\n            \"type\": \"boolean\"\n          },\n          \"ssh_client_auth_password\": {\n            \"type\": \"boolean\"\n          },\n          \"ssh_client_auth_keyboard_interactive\": {\n            \"type\": \"boolean\"\n          },\n          \"minimize_password_login\": {\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"ParameterValues\": {\n        \"type\": \"object\",\n        \"title\": \"ParameterValues\",\n        \"required\": [\n          \"allow_own_credential_management\",\n          \"ssh_client_auth_publickey\",\n          \"ssh_client_auth_password\",\n          \"ssh_client_auth_keyboard_interactive\",\n          \"minimize_password_login\"\n        ],\n        \"properties\": {\n          \"allow_own_credential_management\": {\n            \"type\": \"boolean\"\n          },\n          \"rate_limit_bytes_per_second\": {\n            \"type\": \"integer\",\n            \"format\": \"uint32\"\n          },\n          \"ssh_client_auth_publickey\": {\n            \"type\": \"boolean\"\n          },\n          \"ssh_client_auth_password\": {\n            \"type\": \"boolean\"\n          },\n          \"ssh_client_auth_keyboard_interactive\": {\n            \"type\": \"boolean\"\n          },\n          \"minimize_password_login\": {\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"Recording\": {\n        \"type\": \"object\",\n        \"title\": \"Recording\",\n        \"required\": [\n          \"id\",\n          \"name\",\n          \"started\",\n          \"session_id\",\n          \"kind\",\n          \"metadata\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"started\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"ended\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"session_id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"kind\": {\n            \"$ref\": \"#/components/schemas/RecordingKind\"\n          },\n          \"metadata\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"RecordingKind\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Terminal\",\n          \"Traffic\",\n          \"Kubernetes\"\n        ]\n      },\n      \"Role\": {\n        \"type\": \"object\",\n        \"title\": \"Role\",\n        \"required\": [\n          \"id\",\n          \"name\",\n          \"description\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"RoleDataRequest\": {\n        \"type\": \"object\",\n        \"title\": \"RoleDataRequest\",\n        \"required\": [\n          \"name\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"SSHKey\": {\n        \"type\": \"object\",\n        \"title\": \"SSHKey\",\n        \"required\": [\n          \"kind\",\n          \"public_key_base64\"\n        ],\n        \"properties\": {\n          \"kind\": {\n            \"type\": \"string\"\n          },\n          \"public_key_base64\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"SSHKnownHost\": {\n        \"type\": \"object\",\n        \"title\": \"SSHKnownHost\",\n        \"required\": [\n          \"id\",\n          \"host\",\n          \"port\",\n          \"key_type\",\n          \"key_base64\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"int32\"\n          },\n          \"key_type\": {\n            \"type\": \"string\"\n          },\n          \"key_base64\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"SSHTargetAuth\": {\n        \"type\": \"object\",\n        \"oneOf\": [\n          {\n            \"$ref\": \"#/components/schemas/SSHTargetAuth_SshTargetPasswordAuth\"\n          },\n          {\n            \"$ref\": \"#/components/schemas/SSHTargetAuth_SshTargetPublicKeyAuth\"\n          }\n        ],\n        \"discriminator\": {\n          \"propertyName\": \"kind\",\n          \"mapping\": {\n            \"Password\": \"#/components/schemas/SSHTargetAuth_SshTargetPasswordAuth\",\n            \"PublicKey\": \"#/components/schemas/SSHTargetAuth_SshTargetPublicKeyAuth\"\n          }\n        }\n      },\n      \"SSHTargetAuth_SshTargetPasswordAuth\": {\n        \"allOf\": [\n          {\n            \"type\": \"object\",\n            \"required\": [\n              \"kind\"\n            ],\n            \"properties\": {\n              \"kind\": {\n                \"type\": \"string\",\n                \"enum\": [\n                  \"Password\"\n                ],\n                \"example\": \"Password\"\n              }\n            }\n          },\n          {\n            \"$ref\": \"#/components/schemas/SshTargetPasswordAuth\"\n          }\n        ]\n      },\n      \"SSHTargetAuth_SshTargetPublicKeyAuth\": {\n        \"allOf\": [\n          {\n            \"type\": \"object\",\n            \"required\": [\n              \"kind\"\n            ],\n            \"properties\": {\n              \"kind\": {\n                \"type\": \"string\",\n                \"enum\": [\n                  \"PublicKey\"\n                ],\n                \"example\": \"PublicKey\"\n              }\n            }\n          },\n          {\n            \"$ref\": \"#/components/schemas/SshTargetPublicKeyAuth\"\n          }\n        ]\n      },\n      \"SessionSnapshot\": {\n        \"type\": \"object\",\n        \"title\": \"SessionSnapshot\",\n        \"required\": [\n          \"id\",\n          \"started\",\n          \"protocol\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"target\": {\n            \"$ref\": \"#/components/schemas/Target\"\n          },\n          \"started\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"ended\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"ticket_id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"protocol\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"SshTargetPasswordAuth\": {\n        \"type\": \"object\",\n        \"title\": \"SshTargetPasswordAuth\",\n        \"required\": [\n          \"password\"\n        ],\n        \"properties\": {\n          \"password\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"SshTargetPublicKeyAuth\": {\n        \"type\": \"object\",\n        \"title\": \"SshTargetPublicKeyAuth\"\n      },\n      \"Target\": {\n        \"type\": \"object\",\n        \"title\": \"Target\",\n        \"required\": [\n          \"id\",\n          \"name\",\n          \"description\",\n          \"allow_roles\",\n          \"options\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"allow_roles\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          },\n          \"options\": {\n            \"$ref\": \"#/components/schemas/TargetOptions\"\n          },\n          \"rate_limit_bytes_per_second\": {\n            \"type\": \"integer\",\n            \"format\": \"uint32\"\n          },\n          \"group_id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          }\n        }\n      },\n      \"TargetDataRequest\": {\n        \"type\": \"object\",\n        \"title\": \"TargetDataRequest\",\n        \"required\": [\n          \"name\",\n          \"options\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"options\": {\n            \"$ref\": \"#/components/schemas/TargetOptions\"\n          },\n          \"rate_limit_bytes_per_second\": {\n            \"type\": \"integer\",\n            \"format\": \"uint32\"\n          },\n          \"group_id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          }\n        }\n      },\n      \"TargetGroup\": {\n        \"type\": \"object\",\n        \"title\": \"TargetGroup\",\n        \"required\": [\n          \"id\",\n          \"name\",\n          \"description\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"color\": {\n            \"$ref\": \"#/components/schemas/BootstrapThemeColor\"\n          }\n        }\n      },\n      \"TargetGroupDataRequest\": {\n        \"type\": \"object\",\n        \"title\": \"TargetGroupDataRequest\",\n        \"required\": [\n          \"name\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"color\": {\n            \"$ref\": \"#/components/schemas/BootstrapThemeColor\"\n          }\n        }\n      },\n      \"TargetHTTPOptions\": {\n        \"type\": \"object\",\n        \"title\": \"TargetHTTPOptions\",\n        \"required\": [\n          \"url\",\n          \"tls\"\n        ],\n        \"properties\": {\n          \"url\": {\n            \"type\": \"string\"\n          },\n          \"tls\": {\n            \"$ref\": \"#/components/schemas/Tls\"\n          },\n          \"headers\": {\n            \"type\": \"object\",\n            \"additionalProperties\": {\n              \"type\": \"string\"\n            }\n          },\n          \"external_host\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"TargetKubernetesOptions\": {\n        \"type\": \"object\",\n        \"title\": \"TargetKubernetesOptions\",\n        \"required\": [\n          \"cluster_url\",\n          \"tls\",\n          \"auth\"\n        ],\n        \"properties\": {\n          \"cluster_url\": {\n            \"type\": \"string\"\n          },\n          \"tls\": {\n            \"$ref\": \"#/components/schemas/Tls\"\n          },\n          \"auth\": {\n            \"$ref\": \"#/components/schemas/KubernetesTargetAuth\"\n          }\n        }\n      },\n      \"TargetMySqlOptions\": {\n        \"type\": \"object\",\n        \"title\": \"TargetMySqlOptions\",\n        \"required\": [\n          \"host\",\n          \"port\",\n          \"username\",\n          \"tls\"\n        ],\n        \"properties\": {\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          },\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"password\": {\n            \"type\": \"string\"\n          },\n          \"tls\": {\n            \"$ref\": \"#/components/schemas/Tls\"\n          },\n          \"default_database_name\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"TargetOptions\": {\n        \"type\": \"object\",\n        \"oneOf\": [\n          {\n            \"$ref\": \"#/components/schemas/TargetOptions_TargetSSHOptions\"\n          },\n          {\n            \"$ref\": \"#/components/schemas/TargetOptions_TargetHTTPOptions\"\n          },\n          {\n            \"$ref\": \"#/components/schemas/TargetOptions_TargetKubernetesOptions\"\n          },\n          {\n            \"$ref\": \"#/components/schemas/TargetOptions_TargetMySqlOptions\"\n          },\n          {\n            \"$ref\": \"#/components/schemas/TargetOptions_TargetPostgresOptions\"\n          }\n        ],\n        \"discriminator\": {\n          \"propertyName\": \"kind\",\n          \"mapping\": {\n            \"Ssh\": \"#/components/schemas/TargetOptions_TargetSSHOptions\",\n            \"Http\": \"#/components/schemas/TargetOptions_TargetHTTPOptions\",\n            \"Kubernetes\": \"#/components/schemas/TargetOptions_TargetKubernetesOptions\",\n            \"MySql\": \"#/components/schemas/TargetOptions_TargetMySqlOptions\",\n            \"Postgres\": \"#/components/schemas/TargetOptions_TargetPostgresOptions\"\n          }\n        }\n      },\n      \"TargetOptions_TargetHTTPOptions\": {\n        \"allOf\": [\n          {\n            \"type\": \"object\",\n            \"required\": [\n              \"kind\"\n            ],\n            \"properties\": {\n              \"kind\": {\n                \"type\": \"string\",\n                \"enum\": [\n                  \"Http\"\n                ],\n                \"example\": \"Http\"\n              }\n            }\n          },\n          {\n            \"$ref\": \"#/components/schemas/TargetHTTPOptions\"\n          }\n        ]\n      },\n      \"TargetOptions_TargetKubernetesOptions\": {\n        \"allOf\": [\n          {\n            \"type\": \"object\",\n            \"required\": [\n              \"kind\"\n            ],\n            \"properties\": {\n              \"kind\": {\n                \"type\": \"string\",\n                \"enum\": [\n                  \"Kubernetes\"\n                ],\n                \"example\": \"Kubernetes\"\n              }\n            }\n          },\n          {\n            \"$ref\": \"#/components/schemas/TargetKubernetesOptions\"\n          }\n        ]\n      },\n      \"TargetOptions_TargetMySqlOptions\": {\n        \"allOf\": [\n          {\n            \"type\": \"object\",\n            \"required\": [\n              \"kind\"\n            ],\n            \"properties\": {\n              \"kind\": {\n                \"type\": \"string\",\n                \"enum\": [\n                  \"MySql\"\n                ],\n                \"example\": \"MySql\"\n              }\n            }\n          },\n          {\n            \"$ref\": \"#/components/schemas/TargetMySqlOptions\"\n          }\n        ]\n      },\n      \"TargetOptions_TargetPostgresOptions\": {\n        \"allOf\": [\n          {\n            \"type\": \"object\",\n            \"required\": [\n              \"kind\"\n            ],\n            \"properties\": {\n              \"kind\": {\n                \"type\": \"string\",\n                \"enum\": [\n                  \"Postgres\"\n                ],\n                \"example\": \"Postgres\"\n              }\n            }\n          },\n          {\n            \"$ref\": \"#/components/schemas/TargetPostgresOptions\"\n          }\n        ]\n      },\n      \"TargetOptions_TargetSSHOptions\": {\n        \"allOf\": [\n          {\n            \"type\": \"object\",\n            \"required\": [\n              \"kind\"\n            ],\n            \"properties\": {\n              \"kind\": {\n                \"type\": \"string\",\n                \"enum\": [\n                  \"Ssh\"\n                ],\n                \"example\": \"Ssh\"\n              }\n            }\n          },\n          {\n            \"$ref\": \"#/components/schemas/TargetSSHOptions\"\n          }\n        ]\n      },\n      \"TargetPostgresOptions\": {\n        \"type\": \"object\",\n        \"title\": \"TargetPostgresOptions\",\n        \"required\": [\n          \"host\",\n          \"port\",\n          \"username\",\n          \"tls\"\n        ],\n        \"properties\": {\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          },\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"password\": {\n            \"type\": \"string\"\n          },\n          \"tls\": {\n            \"$ref\": \"#/components/schemas/Tls\"\n          },\n          \"idle_timeout\": {\n            \"type\": \"string\"\n          },\n          \"default_database_name\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"TargetSSHOptions\": {\n        \"type\": \"object\",\n        \"title\": \"TargetSSHOptions\",\n        \"required\": [\n          \"host\",\n          \"port\",\n          \"username\",\n          \"auth\"\n        ],\n        \"properties\": {\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          },\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"allow_insecure_algos\": {\n            \"type\": \"boolean\"\n          },\n          \"auth\": {\n            \"$ref\": \"#/components/schemas/SSHTargetAuth\"\n          }\n        }\n      },\n      \"TestLdapServerRequest\": {\n        \"type\": \"object\",\n        \"title\": \"TestLdapServerRequest\",\n        \"required\": [\n          \"host\",\n          \"port\",\n          \"bind_dn\",\n          \"bind_password\",\n          \"tls_mode\",\n          \"tls_verify\"\n        ],\n        \"properties\": {\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"int32\"\n          },\n          \"bind_dn\": {\n            \"type\": \"string\"\n          },\n          \"bind_password\": {\n            \"type\": \"string\"\n          },\n          \"tls_mode\": {\n            \"$ref\": \"#/components/schemas/TlsMode\"\n          },\n          \"tls_verify\": {\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"TestLdapServerResponse\": {\n        \"type\": \"object\",\n        \"title\": \"TestLdapServerResponse\",\n        \"required\": [\n          \"success\",\n          \"message\"\n        ],\n        \"properties\": {\n          \"success\": {\n            \"type\": \"boolean\"\n          },\n          \"message\": {\n            \"type\": \"string\"\n          },\n          \"base_dns\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          }\n        }\n      },\n      \"Ticket\": {\n        \"type\": \"object\",\n        \"title\": \"Ticket\",\n        \"required\": [\n          \"id\",\n          \"username\",\n          \"description\",\n          \"target\",\n          \"created\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"target\": {\n            \"type\": \"string\"\n          },\n          \"uses_left\": {\n            \"type\": \"integer\",\n            \"format\": \"int16\"\n          },\n          \"expiry\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"created\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          }\n        }\n      },\n      \"TicketAndSecret\": {\n        \"type\": \"object\",\n        \"title\": \"TicketAndSecret\",\n        \"required\": [\n          \"ticket\",\n          \"secret\"\n        ],\n        \"properties\": {\n          \"ticket\": {\n            \"$ref\": \"#/components/schemas/Ticket\"\n          },\n          \"secret\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"Tls\": {\n        \"type\": \"object\",\n        \"title\": \"Tls\",\n        \"required\": [\n          \"mode\",\n          \"verify\"\n        ],\n        \"properties\": {\n          \"mode\": {\n            \"$ref\": \"#/components/schemas/TlsMode\"\n          },\n          \"verify\": {\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"TlsMode\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Disabled\",\n          \"Preferred\",\n          \"Required\"\n        ]\n      },\n      \"UpdateCertificateCredential\": {\n        \"type\": \"object\",\n        \"title\": \"UpdateCertificateCredential\",\n        \"required\": [\n          \"label\"\n        ],\n        \"properties\": {\n          \"label\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"UpdateLdapServerRequest\": {\n        \"type\": \"object\",\n        \"title\": \"UpdateLdapServerRequest\",\n        \"required\": [\n          \"name\",\n          \"host\",\n          \"port\",\n          \"bind_dn\",\n          \"user_filter\",\n          \"tls_mode\",\n          \"tls_verify\",\n          \"enabled\",\n          \"auto_link_sso_users\",\n          \"username_attribute\",\n          \"ssh_key_attribute\",\n          \"uuid_attribute\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"host\": {\n            \"type\": \"string\"\n          },\n          \"port\": {\n            \"type\": \"integer\",\n            \"format\": \"int32\"\n          },\n          \"bind_dn\": {\n            \"type\": \"string\"\n          },\n          \"bind_password\": {\n            \"type\": \"string\"\n          },\n          \"user_filter\": {\n            \"type\": \"string\"\n          },\n          \"tls_mode\": {\n            \"$ref\": \"#/components/schemas/TlsMode\"\n          },\n          \"tls_verify\": {\n            \"type\": \"boolean\"\n          },\n          \"enabled\": {\n            \"type\": \"boolean\"\n          },\n          \"auto_link_sso_users\": {\n            \"type\": \"boolean\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"username_attribute\": {\n            \"$ref\": \"#/components/schemas/LdapUsernameAttribute\"\n          },\n          \"ssh_key_attribute\": {\n            \"type\": \"string\"\n          },\n          \"uuid_attribute\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"User\": {\n        \"type\": \"object\",\n        \"title\": \"User\",\n        \"required\": [\n          \"id\",\n          \"username\",\n          \"description\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"credential_policy\": {\n            \"$ref\": \"#/components/schemas/UserRequireCredentialsPolicy\"\n          },\n          \"rate_limit_bytes_per_second\": {\n            \"type\": \"integer\",\n            \"format\": \"int64\"\n          },\n          \"ldap_server_id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          }\n        }\n      },\n      \"UserDataRequest\": {\n        \"type\": \"object\",\n        \"title\": \"UserDataRequest\",\n        \"required\": [\n          \"username\"\n        ],\n        \"properties\": {\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"credential_policy\": {\n            \"$ref\": \"#/components/schemas/UserRequireCredentialsPolicy\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"rate_limit_bytes_per_second\": {\n            \"type\": \"integer\",\n            \"format\": \"uint32\"\n          }\n        }\n      },\n      \"UserRequireCredentialsPolicy\": {\n        \"type\": \"object\",\n        \"title\": \"UserRequireCredentialsPolicy\",\n        \"properties\": {\n          \"http\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          },\n          \"kubernetes\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          },\n          \"ssh\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          },\n          \"mysql\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          },\n          \"postgres\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          }\n        }\n      }\n    },\n    \"securitySchemes\": {\n      \"CookieSecurityScheme\": {\n        \"type\": \"apiKey\",\n        \"name\": \"warpgate-http-session\",\n        \"in\": \"cookie\"\n      },\n      \"TokenSecurityScheme\": {\n        \"type\": \"apiKey\",\n        \"name\": \"X-Warpgate-Token\",\n        \"in\": \"header\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "warpgate-web/src/admin/lib/store.ts",
    "content": "import { derived } from 'svelte/store'\nimport { serverInfo } from 'gateway/lib/store'\nimport type { AdminPermissions } from 'gateway/lib/api'\n\nexport interface AdminPermissionDef {\n    key: string\n    label: string\n    category?: string\n    deps?: string[]\n    dangerous?: boolean\n}\n\nexport const ADMIN_PERMISSIONS = [\n    {\n        key: 'targetsCreate' as const,\n        label: 'Create',\n        category: 'Targets' as const,\n        deps: ['targetsEdit'],\n    },\n    {\n        key: 'targetsEdit' as const,\n        label: 'Edit',\n        category: 'Targets' as const,\n    },\n    {\n        key: 'targetsDelete' as const, label: 'Delete',\n        category: 'Targets' as const,\n        deps: ['targetsCreate', 'targetsEdit'],\n    },\n    {\n        key: 'usersCreate' as const,\n        label: 'Create',\n        category: 'Users' as const,\n        deps: ['usersEdit'],\n    },\n    {\n        key: 'usersEdit' as const,\n        label: 'Edit',\n        category: 'Users' as const,\n    },\n    {\n        key: 'usersDelete' as const, label: 'Delete',\n        category: 'Users' as const,\n        deps: ['usersCreate', 'usersEdit'],\n    },\n    {\n        key: 'accessRolesCreate' as const,\n        label: 'Create',\n        category: 'Access roles' as const,\n        deps: ['accessRolesEdit'],\n    },\n    {\n        key: 'accessRolesEdit' as const,\n        label: 'Edit',\n        category: 'Access roles' as const,\n    },\n    {\n        key: 'accessRolesDelete' as const, label: 'Delete',\n        category: 'Access roles' as const,\n        deps: ['accessRolesCreate', 'accessRolesEdit'],\n    },\n    {\n        key: 'accessRolesAssign' as const,\n        label: 'Assign',\n        category: 'Access roles' as const,\n    },\n    {\n        key: 'sessionsView' as const,\n        label: 'View',\n        category: 'Sessions' as const,\n    },\n    {\n        key: 'sessionsTerminate' as const, label: 'Terminate',\n        category: 'Sessions' as const,\n        deps: ['sessionsView'],\n    },\n    {\n        key: 'recordingsView' as const,\n        label: 'View',\n        category: 'Recordings' as const,\n    },\n    {\n        key: 'ticketsCreate' as const,\n        label: 'Create',\n        category: 'Tickets' as const,\n    },\n    {\n        key: 'ticketsDelete' as const, label: 'Delete',\n        category: 'Tickets' as const,\n        deps: ['ticketsCreate'],\n    },\n    {\n        key: 'configEdit' as const,\n        label: 'Edit configuration',\n        category: 'Configuration' as const,\n    },\n    {\n        key: 'adminRolesManage' as const,\n        label: 'Manage admin roles',\n        category: 'Configuration', dangerous: true } as const,\n]\n\n// eslint-disable-next-line @typescript-eslint/no-type-alias\nexport type AdminPermission = typeof ADMIN_PERMISSIONS[number]\n// eslint-disable-next-line @typescript-eslint/no-type-alias\nexport type AdminPermissionKey = AdminPermission['key']\n// eslint-disable-next-line @typescript-eslint/no-type-alias\nexport type AdminPermissionCategory = AdminPermission['category']\n\nexport function emptyPermissions(): AdminPermissions {\n    return ADMIN_PERMISSIONS.reduce(\n        (acc, {\n            key }) => ({ ...acc,\n            [key]: false }),\n        {} as unknown as AdminPermissions,\n    )\n}\n\nexport const adminPermissions = derived(serverInfo, $serverInfo => {\n    return $serverInfo?.adminPermissions ?? emptyPermissions()\n})\n\nexport const hasAdminAccess = derived(adminPermissions, $adminPermissions => {\n    return Object.values($adminPermissions).some(v => v)\n})\n"
  },
  {
    "path": "warpgate-web/src/admin/lib/time.ts",
    "content": "import { formatDistanceToNow } from 'date-fns'\n\nexport function timeAgo(t: Date): string {\n    return formatDistanceToNow(t, { addSuffix: true })\n}\n"
  },
  {
    "path": "warpgate-web/src/admin/player/TerminalRecordingPlayer.svelte",
    "content": "<script lang=\"ts\">\n    import Fa from 'svelte-fa'\n    import { onDestroy, onMount } from 'svelte'\n    import { Terminal } from '@xterm/xterm'\n    import { SerializeAddon } from '@xterm/addon-serialize'\n    import { faPlay, faPause, faExpand } from '@fortawesome/free-solid-svg-icons'\n    import { Spinner } from '@sveltestrap/sveltestrap'\n    import formatDuration from 'format-duration'\n    import type { Recording } from 'admin/lib/api'\n\n    export let recording: Recording\n\n    let url: string\n    let containerElement: HTMLDivElement\n    let rootElement: HTMLDivElement\n    let timestamp = 0\n    let seekInputValue = 0\n    let duration = 0\n    let resizeObserver: ResizeObserver|undefined\n    let events: (DataEvent | SizeEvent | SnapshotEvent)[] = []\n    let playing = false\n    let loading = true\n    let sessionIsLive: boolean|null = null\n    let socket: WebSocket|null = null\n    let isStreaming = false\n    let ptyMode = false\n\n    $: isStreaming = timestamp === duration && playing\n\n    const COLOR_NAMES = [\n        'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',\n        'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite',\n    ]\n\n    const theme: Record<string, string> = {\n        foreground: '#ffcb83',\n        background: '#262626',\n        cursor: '#fc531d',\n    }\n    const colors = [\n        '#000000',\n        '#c13900',\n        '#a4a900',\n        '#caaf00',\n        '#bd6d00',\n        '#fc5e00',\n        '#f79500',\n        '#ffc88a',\n        '#6a4f2a',\n        '#ff8c68',\n        '#f6ff40',\n        '#ffe36e',\n        '#ffbe55',\n        '#fc874f',\n        '#c69752',\n        '#fafaff',\n    ]\n    for (let i = 0; i < COLOR_NAMES.length; i++) {\n        theme[COLOR_NAMES[i]!] = colors[i]!\n    }\n\n    interface AsciiCastHeader {\n        time: number\n        version: number\n        width: number\n        height: number\n    }\n    // eslint-disable-next-line @typescript-eslint/no-type-alias\n    type AsciiCastData = [number, 'o', string]\n    type AsciiCastItem = AsciiCastData | AsciiCastHeader\n\n    function isAsciiCastHeader (data: AsciiCastItem): data is AsciiCastHeader {\n        return 'version' in data\n    }\n\n    function isAsciiCastData (data: AsciiCastItem): data is AsciiCastData {\n        if (data instanceof Array) {\n            return data[1] === 'o' || data[1] === 'e'\n        } else {\n            return false\n        }\n    }\n\n    interface SizeEvent { time: number, cols: number, rows: number }\n    interface DataEvent { time: number, data: string }\n    interface SnapshotEvent { time: number, snapshot: string }\n\n    const term = new Terminal()\n    const serializeAddon = new SerializeAddon()\n\n    onDestroy(() => socket?.close())\n\n    onMount(async () => {\n        if (recording.kind !== 'Terminal') {\n            throw new Error('Invalid recording type')\n        }\n\n        url = `/@warpgate/admin/api/recordings/${recording.id}/cast`\n\n        term.loadAddon(serializeAddon)\n        term.open(containerElement)\n\n        term.options.theme = theme\n        term.options.scrollback = 100\n\n        fitSize()\n        resizeObserver = new ResizeObserver(fitSize)\n        resizeObserver.observe(containerElement)\n\n        const data = await fetch(url).then(r => r.text())\n        for (const line of data.split('\\n')) {\n            addData(JSON.parse(line))\n        }\n\n        await seek(duration)\n\n        socket = new WebSocket(`wss://${location.host}/@warpgate/admin/api/recordings/${recording.id}/stream`)\n        socket.addEventListener('message', function (event) {\n            let message = JSON.parse(event.data)\n            if ('data' in message) {\n                let item: AsciiCastItem = message.data\n                addData(item)\n            } if ('start' in message) {\n                sessionIsLive = message.live\n                if (!sessionIsLive) {\n                    seek(0)\n                } else {\n                    playing = true\n                }\n            } if ('end' in message) {\n                sessionIsLive = false\n            } else {\n                console.log('Message from server ', message)\n            }\n        })\n        socket.addEventListener('close', () => console.info('Live stream closed'))\n\n        loading = false\n    })\n\n    async function writeToTerminal (data: string) {\n        if (!ptyMode) {\n            data = data.replace(/\\n/g, '\\r\\n')\n        }\n        await new Promise<void>(r => term.write(data, r))\n    }\n\n    function addData (data: AsciiCastItem) {\n        if (isAsciiCastHeader(data)) {\n            if (data.width) {\n                ptyMode = true\n            }\n            events.push({\n                time: data.time,\n                cols: data.width,\n                rows: data.height,\n            })\n            if (isStreaming) {\n                resize(data.width, data.height)\n                timestamp = data.time\n            }\n            duration = Math.max(duration, data.time)\n        }\n        if (isAsciiCastData(data)) {\n            let dataEvent = {\n                time: data[0],\n                data: data[2],\n            }\n            events.push(dataEvent)\n            if (isStreaming) {\n                writeToTerminal(dataEvent.data)\n                timestamp = dataEvent.time\n            }\n            duration = Math.max(duration, dataEvent.time)\n        }\n    }\n\n    let metricsCanvas: HTMLCanvasElement\n    function fitSize () {\n        metricsCanvas ??= document.createElement('canvas')\n        const context = metricsCanvas.getContext('2d')!\n        context.font = `10px ${term.options.fontFamily ?? 'monospace'}`\n        const metrics = context.measureText('abcdef')\n\n        const fontWidth = containerElement.clientWidth / term.cols\n        term.options.fontSize = fontWidth / (metrics.width / 6) * 10\n    }\n\n    let seekPromise = Promise.resolve()\n\n    async function seek (time: number) {\n        seekPromise = seekPromise.then(() => _seekInternal(time))\n        await seekPromise\n    }\n\n    async function _seekInternal (time: number) {\n        let nearestSnapshot: SnapshotEvent|null = null\n\n        for (const event of events) {\n            if (event.time > time) {\n                break\n            }\n            if ('snapshot' in event) {\n                nearestSnapshot = event\n            }\n        }\n\n        let index = nearestSnapshot ? events.indexOf(nearestSnapshot) : 0\n        if (time >= timestamp) {\n            const nextEventIndex = events.findIndex(e => e.time > timestamp)\n            if (nextEventIndex === -1) {\n                return\n            }\n            index = Math.max(index, nextEventIndex)\n        }\n        let lastSize = { cols: term.cols, rows: term.rows }\n\n        for (let i = 0; i <= index; i++) {\n            let event = events[i]!\n            if ('cols' in event) {\n                lastSize = { cols: event.cols, rows: event.rows }\n            }\n        }\n\n        resize(lastSize.cols, lastSize.rows)\n\n        let output = ''\n\n        async function flush () {\n            await writeToTerminal(output)\n            output = ''\n        }\n\n        for (let i = index; i < events.length; i++) {\n            let shouldSnapshot = false\n            let event = events[i]!\n            if (event.time > time) {\n                break\n            }\n            if ('snapshot' in event) {\n                output += '\\x1bc' + event.snapshot\n            }\n            if ('cols' in event) {\n                await flush()\n                resize(event.cols, event.rows)\n                shouldSnapshot = true\n            }\n            if ('data' in event) {\n                output += event.data\n            }\n\n            shouldSnapshot ||= output.length > 1000\n\n            if (shouldSnapshot) {\n                await flush()\n                events.splice(i + 1, 0, {\n                    time: event.time,\n                    snapshot: serializeAddon.serialize(),\n                })\n                i++\n            }\n        }\n\n        await flush()\n\n        timestamp = time\n        seekInputValue = 100 * time / duration\n    }\n\n    function resize (cols: number, rows: number) {\n        if (term.cols === cols && term.rows === rows) {\n            return\n        }\n        if (cols && rows) {\n            term.resize(cols, rows)\n        }\n        fitSize()\n    }\n\n    onDestroy(() => resizeObserver?.disconnect())\n\n    let destroyed = false\n    onDestroy(() => destroyed = true)\n\n    async function step () {\n        if (destroyed) {\n            return\n        }\n        if (playing) {\n            await seek(Math.min(duration, timestamp + 0.1))\n        }\n        setTimeout(step, 100)\n    }\n\n    function togglePlaying () {\n        playing = !playing\n    }\n\n    function keyPressHandler (event: KeyboardEvent) {\n        if (event.key === ' ') {\n            togglePlaying()\n        }\n    }\n\n    step()\n\n    function toggleFullscreen () {\n        if (document.fullscreenElement) {\n            document.exitFullscreen()\n        } else {\n            rootElement.requestFullscreen()\n        }\n    }\n</script>\n\n<div class=\"root\" bind:this={rootElement} style=\"background: {theme.background}\">\n    {#if loading}\n    <Spinner color=\"primary\" />\n    {/if}\n\n    {#if !loading && !playing}\n    <div class=\"pause-overlay\">\n        <Fa icon={faPlay} size=\"2x\" fw />\n    </div>\n    {/if}\n\n    <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->\n    <div\n        class=\"container\"\n        class:invisible={loading}\n        on:click={togglePlaying}\n        on:keypress={keyPressHandler}\n        role=\"img\"\n        bind:this={containerElement}\n    ></div>\n\n    <div class=\"toolbar\" class:invisible={loading}>\n        <button class=\"btn btn-link\" on:click={togglePlaying}>\n            <Fa icon={playing ? faPause : faPlay} fw />\n        </button>\n        <pre\n            class=\"timestamp\"\n        >{ formatDuration(timestamp * 1000, { leading: true }) }</pre>\n        {#if sessionIsLive === true}\n            <button\n                class=\"btn live-btn\"\n                class:active={isStreaming}\n                on:click={() => seek(duration)}\n            >LIVE</button>\n        {/if}\n        <input\n            class=\"w-100\"\n            type=\"range\"\n            min=\"0\" max=\"100\" step=\"0.001\"\n            style=\"background-size: {seekInputValue}% 100%;\"\n            bind:value={seekInputValue}\n            on:input={() => seek(duration * seekInputValue / 100)} />\n        <button class=\"btn btn-link\" on:click={toggleFullscreen}>\n            <Fa icon={faExpand} fw />\n        </button>\n    </div>\n</div>\n\n<style lang=\"scss\">\n    @import \"../../../node_modules/@xterm/xterm/css/xterm.css\";\n\n    .root {\n        border-radius: 5px;\n        overflow: hidden;\n        position: relative;\n        contain: content;\n        display: flex;\n        flex-direction: column;\n    }\n\n    .container {\n        padding: 5px;\n        margin: auto;\n    }\n\n    .toolbar {\n        display: flex;\n    }\n\n    :global(.xterm) {\n        cursor: pointer !important;\n    }\n\n    .btn {\n        color: #eee;\n\n        :global(svg) {\n            transition: all .25s ease-out;\n            &:hover {\n                transform: scale(1.2);\n            }\n        }\n    }\n\n    :global(.spinner-border), .pause-overlay {\n        position: absolute;\n        left: 50%;\n        top: 50%;\n        margin: -12px 0 0 -12px;\n        z-index: 1;\n    }\n\n    .pause-overlay {\n        width: 24px;\n        text-align: center;\n        color: white;\n    }\n\n    input[type=\"range\"] {\n        appearance: none;\n        -webkit-appearance: none;\n        margin: 18px 10px 0;\n        height: 2px;\n        background: #ffffff99;\n        border-radius: 5px;\n        background: linear-gradient(#eee, #eee);\n        background-repeat: no-repeat;\n        cursor: pointer;\n\n        &:hover::-webkit-slider-thumb {\n            transform: scale(1.5);\n        }\n    }\n\n    input[type=\"range\"]::-webkit-slider-thumb {\n        -webkit-appearance: none;\n        height: 10px;\n        width: 10px;\n        border-radius: 50%;\n        background: #eee;\n        transition: all .25s ease-out;\n    }\n\n    input[type=range]::-webkit-slider-runnable-track  {\n        -webkit-appearance: none;\n        box-shadow: none;\n        border: none;\n        background: transparent;\n    }\n\n    .timestamp {\n        flex: none;\n        overflow: visible;\n        color: #eeeeee;\n        margin: 0;\n        font-size: 0.75rem;\n        align-self: center;\n    }\n\n    .live-btn {\n        font-size: 0.75rem;\n        align-self: center;\n        color: red;\n        flex: none;\n\n        &.active {\n            background: red;\n            color: white;\n            padding: 0.1rem 0.25rem;\n            margin: 0 0.5rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/AsyncButton.svelte",
    "content": "<script lang=\"ts\">\nimport { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons'\nimport Fa from 'svelte-fa'\nimport { Button, Spinner, type Color } from '@sveltestrap/sveltestrap'\n\n// svelte-ignore non_reactive_update\nenum State {\n    Normal = 'n',\n    Progress = 'p',\n    ProgressWithSpinner = 'ps',\n    Done = 'd',\n    Failed = 'f'\n}\n\ninterface Props {\n    click: CallableFunction\n    color?: Color | 'link'\n    disabled?: boolean\n    outline?: boolean\n    type?: 'button' | 'submit' | 'reset'\n    class?: string\n    children: () => any\n}\n\n// eslint-disable-next-line svelte/no-unused-props\nlet { children, click, color  = 'secondary', disabled = false, outline = false, type = 'submit', 'class': cls = '' }: Props = $props()\n\nlet button: HTMLElement | undefined = $state()\nlet lastWidth = $state(0)\nlet st = $state(State.Normal)\n\nasync function _click () {\n    if (!button) {\n        return\n    }\n\n    const parentForm = button.closest<HTMLFormElement>('form')\n    if (parentForm) {\n        parentForm.classList.add('was-validated')\n        if (!parentForm.checkValidity()) {\n            return\n        }\n    }\n\n    lastWidth = button.offsetWidth\n    st = State.Progress\n    setTimeout(() => {\n        if (st === State.Progress) {\n            st = State.ProgressWithSpinner\n        }\n    }, 500)\n    try {\n        await click()\n        st = State.Done\n    } catch (e) {\n        st = State.Failed\n        throw e\n    } finally {\n        setTimeout(() => {\n            if (st === State.Done || st === State.Failed) {\n                st = State.Normal\n                lastWidth = 0\n            }\n        }, 1000)\n    }\n}\n\n</script>\n\n<Button\n    on:click={_click}\n    bind:inner={button}\n    style=\"min-width: {lastWidth}px; min-height: 40px;\"\n    class={cls}\n    outline={outline}\n    color={color}\n    type={type}\n    disabled={disabled || st === State.Progress || st === State.ProgressWithSpinner}\n>\n    {#if st === State.Normal || st === State.Progress}\n        {@render children?.()}\n    {/if}\n    <div class=\"overlay\">\n        {#if st === State.ProgressWithSpinner}\n            <Spinner size=\"sm\" />\n        {/if}\n        {#if st === State.Done}\n            <Fa icon={faCheck} fw />\n        {/if}\n        {#if st === State.Failed}\n            <Fa icon={faTimes} fw />\n        {/if}\n    </div>\n</Button>\n\n<style lang=\"scss\">\n    .overlay {\n        margin: auto;\n\n        :global(svg) {\n            margin: auto;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/AuthBar.svelte",
    "content": "<script lang=\"ts\">\nimport { faSignOut } from '@fortawesome/free-solid-svg-icons'\nimport Fa from 'svelte-fa'\n\nimport { api } from 'gateway/lib/api'\nimport { serverInfo, reloadServerInfo } from 'gateway/lib/store'\nimport { Button, Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from '@sveltestrap/sveltestrap'\n\nasync function logout () {\n    await api.logout()\n    await reloadServerInfo()\n    location.href = '/@warpgate'\n}\n\nasync function singleLogout () {\n    const response = await api.initiateSsoLogout()\n    location.href = response.url\n}\n</script>\n\n{#if $serverInfo?.username}\n    <a href=\"/@warpgate/#/profile\">\n        {$serverInfo.username}\n    </a>\n    {#if $serverInfo.authorizedViaTicket}\n        <span class=\"ml-2\">(ticket auth)</span>\n    {/if}\n\n    {#if $serverInfo?.authorizedViaSsoWithSingleLogout}\n        <Dropdown>\n            <DropdownToggle color=\"link\" title=\"Log out options\">\n                <Fa icon={faSignOut} fw />\n            </DropdownToggle>\n            <DropdownMenu right={true}>\n                <DropdownItem on:click={logout}>\n                    <Fa icon={faSignOut} fw />\n                    Log out of Warpgate\n                </DropdownItem>\n                <DropdownItem on:click={singleLogout}>\n                    <Fa icon={faSignOut} fw />\n                    Log out everywhere\n                </DropdownItem>\n            </DropdownMenu>\n        </Dropdown>\n    {:else}\n        <Button color=\"link\" on:click={logout} title=\"Log out\" class=\"p-0 ms-2\">\n            <Fa icon={faSignOut} fw />\n        </Button>\n    {/if}\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/common/Brand.svelte",
    "content": "<script lang=\"ts\">\n    import { onDestroy, onMount } from 'svelte'\n    import logo from '../../public/assets/brand.svg?raw'\n    import { currentThemeFile } from 'theme'\n    import { get } from 'svelte/store'\n\n    let element: HTMLElement|undefined = $state()\n\n    let s = currentThemeFile.subscribe(colorizeByTheme)\n\n    function colorize (r: number, g: number, b: number, dr: number, dg: number, db: number) {\n        element?.querySelectorAll('path').forEach((p, idx) => {\n            let d = idx\n            p.style.fill = `rgb(${r + d * dr}, ${g + d * dg}, ${b + d * db})`\n        })\n    }\n\n    function colorizeByTheme () {\n        if (get(currentThemeFile) === 'light') {\n            colorize(49, 57, 72, -1, 1, 3)\n        } else {\n            colorize(203, 212, 235, -3, -2, -1)\n        }\n    }\n\n    onMount(() => {\n        colorizeByTheme()\n    })\n\nonDestroy(s)\n</script>\n\n\n<div bind:this={element} class=\"brand\">\n    <!-- eslint-disable-next-line svelte/no-at-html-tags -->\n    {@html logo}\n</div>\n\n\n<style lang=\"scss\">\n    :global(svg) {\n        width: auto;\n        display: block;\n        max-height: 100%;\n    }\n\n    .brand {\n        height: 22px;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/ConnectionInstructions.svelte",
    "content": "<script lang=\"ts\">\n    import { FormGroup } from '@sveltestrap/sveltestrap'\n    import { TargetKind } from 'gateway/lib/api'\n    import { serverInfo } from 'gateway/lib/store'\n    import { makeExampleSSHCommand, makeSSHUsername, makeExampleMySQLCommand, makeExampleMySQLURI, makeMySQLUsername, makeTargetURL, makeExamplePostgreSQLCommand, makePostgreSQLUsername, makeExamplePostgreSQLURI, makeKubeconfig, makeExampleKubectlCommand, makeExampleSCPCommand } from 'common/protocols'\n    import CopyButton from 'common/CopyButton.svelte'\n    import Alert from './sveltestrap-s5-ports/Alert.svelte'\n\n    interface Props {\n        targetName?: string;\n        targetKind: TargetKind;\n        targetExternalHost?: string;\n        username?: string;\n        ticketSecret?: string;\n        targetDefaultDatabaseName?: string;\n    }\n\n    let {\n        targetName,\n        targetKind,\n        targetExternalHost = undefined,\n        username,\n        ticketSecret = undefined,\n        targetDefaultDatabaseName = undefined,\n    }: Props = $props()\n\n    // Create a reactive opts object that updates when any prop or serverInfo changes\n    let opts = $derived.by(() => ({\n        targetName,\n        username,\n        serverInfo: $serverInfo,\n        ticketSecret,\n        targetExternalHost,\n        targetDefaultDatabaseName,\n    }))\n\n    let sshUsername = $derived(makeSSHUsername(opts))\n    let exampleSSHCommand = $derived(makeExampleSSHCommand(opts))\n    let exampleSCPCommand = $derived(makeExampleSCPCommand(opts))\n    let mySQLUsername = $derived(makeMySQLUsername(opts))\n    let exampleMySQLCommand = $derived(makeExampleMySQLCommand(opts))\n    let exampleMySQLURI = $derived(makeExampleMySQLURI(opts))\n    let postgreSQLUsername = $derived(makePostgreSQLUsername(opts))\n    let examplePostgreSQLCommand = $derived(makeExamplePostgreSQLCommand(opts))\n    let examplePostgreSQLURI = $derived(makeExamplePostgreSQLURI(opts))\n    let targetURL = $derived(targetName ? makeTargetURL(opts) : '')\n    let authHeader = $derived(`Authorization: Warpgate ${ticketSecret}`)\n    let kubeconfig = $derived(makeKubeconfig(opts))\n    let exampleKubectlCommand = $derived(makeExampleKubectlCommand(opts))\n</script>\n\n{#if targetKind === TargetKind.Ssh}\n    <FormGroup floating label=\"SSH username\" class=\"d-flex align-items-center\">\n        <input type=\"text\" class=\"form-control\" readonly value={sshUsername} />\n        <CopyButton text={sshUsername} />\n    </FormGroup>\n\n    <FormGroup floating label=\"Example SSH command\" class=\"d-flex align-items-center\">\n        <input type=\"text\" class=\"form-control\" readonly value={exampleSSHCommand} />\n        <CopyButton text={exampleSSHCommand} />\n    </FormGroup>\n\n    <FormGroup floating label=\"Example SCP command\" class=\"d-flex align-items-center\">\n        <input type=\"text\" class=\"form-control\" readonly value={exampleSCPCommand} />\n        <CopyButton text={exampleSCPCommand} />\n    </FormGroup>\n{/if}\n\n{#if targetKind === TargetKind.Http}\n    <FormGroup floating label=\"Access URL\" class=\"d-flex align-items-center\">\n        <input type=\"text\" class=\"form-control\" readonly value={targetURL} />\n        <CopyButton text={targetURL} />\n    </FormGroup>\n\n    {#if ticketSecret}\n        Alternatively, set the <code>Authorization</code> header when accessing the URL:\n        <FormGroup floating label=\"Authorization header\" class=\"d-flex align-items-center\">\n            <input type=\"text\" class=\"form-control\" readonly value={authHeader} />\n            <CopyButton text={authHeader} />\n        </FormGroup>\n    {/if}\n{/if}\n\n{#if targetKind === TargetKind.MySql}\n    <FormGroup floating label=\"MySQL username\" class=\"d-flex align-items-center\">\n        <input type=\"text\" class=\"form-control\" readonly value={mySQLUsername} />\n        <CopyButton text={mySQLUsername} />\n    </FormGroup>\n\n    <FormGroup floating label=\"Example command\" class=\"d-flex align-items-center\">\n        <input type=\"text\" class=\"form-control\" readonly value={exampleMySQLCommand} />\n        <CopyButton text={exampleMySQLCommand} />\n    </FormGroup>\n\n    <FormGroup floating label=\"Example database URL\" class=\"d-flex align-items-center\">\n        <input type=\"text\" class=\"form-control\" readonly value={exampleMySQLURI} />\n        <CopyButton text={exampleMySQLURI} />\n    </FormGroup>\n\n    <Alert color=\"info\">\n        Make sure you've set your client to require TLS and allowed cleartext password authentication.\n    </Alert>\n{/if}\n\n{#if targetKind === TargetKind.Postgres}\n<FormGroup floating label=\"PostgreSQL username\" class=\"d-flex align-items-center\">\n    <input type=\"text\" class=\"form-control\" readonly value={postgreSQLUsername} />\n    <CopyButton text={postgreSQLUsername} />\n</FormGroup>\n\n<FormGroup floating label=\"Example command\" class=\"d-flex align-items-center\">\n    <input type=\"text\" class=\"form-control\" readonly value={examplePostgreSQLCommand} />\n    <CopyButton text={examplePostgreSQLCommand} />\n</FormGroup>\n\n<FormGroup floating label=\"Example database URL\" class=\"d-flex align-items-center\">\n    <input type=\"text\" class=\"form-control\" readonly value={examplePostgreSQLURI} />\n    <CopyButton text={examplePostgreSQLURI} />\n</FormGroup>\n\n<Alert color=\"info\">\n    Make sure you've set your client to require TLS and allowed cleartext password authentication.\n</Alert>\n{/if}\n\n{#if targetKind === TargetKind.Kubernetes}\n<FormGroup floating label=\"Kubeconfig file\" class=\"d-flex align-items-center\">\n    <textarea class=\"form-control\" readonly style=\"height: 27rem; font-family: monospace; font-size: 0.9em;\">{kubeconfig}</textarea>\n    <CopyButton text={kubeconfig} />\n</FormGroup>\n\n<FormGroup floating label=\"Example kubectl command\" class=\"d-flex align-items-center\">\n    <input type=\"text\" class=\"form-control\" readonly value={exampleKubectlCommand} />\n    <CopyButton text={exampleKubectlCommand} />\n</FormGroup>\n\n<Alert color=\"info\">\n    Save the kubeconfig above to a file (e.g., <code>warpgate-kubeconfig.yaml</code>) and use it with kubectl.\n    {#if !ticketSecret}\n        You'll need to replace the placeholder certificate and key data with your actual credentials.\n    {/if}\n</Alert>\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/common/CopyButton.svelte",
    "content": "<script lang=\"ts\">\n    import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'\n    import Fa from 'svelte-fa'\n    import { Button, type Color } from '@sveltestrap/sveltestrap'\n    import copyTextToClipboard from 'copy-text-to-clipboard'\n\n    interface Props {\n        text: string\n        disabled?: boolean\n        outline?: boolean\n        link?: boolean\n        color?: Color | 'link'\n        class?: string\n        label?: string\n        children?: () => any\n    }\n\n    // eslint-disable-next-line svelte/no-unused-props\n    let {\n        text,\n        disabled = false,\n        outline = false,\n        link = false,\n        color = 'link',\n        label,\n        'class': className = '',\n        children,\n    }: Props = $props()\n    let successVisible = $state(false)\n    let button: HTMLElement | undefined = $state()\n\n    async function _click () {\n        if (disabled) {\n            return\n        }\n        successVisible = true\n        copyTextToClipboard(text)\n        setTimeout(() => {\n            successVisible = false\n        }, 2000)\n    }\n\n</script>\n\n{#if link}\n    <!-- svelte-ignore a11y_invalid_attribute -->\n    <a\n        href=\"#\"\n        class={className}\n        class:disabled={disabled}\n        onclick={e => {\n            _click()\n            e.preventDefault()\n        }}\n        bind:this={button}\n    >\n        {#if children}{@render children()}{:else}\n            {#if successVisible}\n                Copied\n            {:else}\n                Copy\n            {/if}\n        {/if}\n    </a>\n{:else}\n    <Button\n        class={className}\n        bind:inner={button}\n        on:click={_click}\n        outline={outline}\n        color={color}\n        disabled={disabled}\n    >\n        {#if children}{@render children()}{:else}\n            {#if successVisible}\n                <Fa fw icon={faCheck} />\n            {:else}\n                <Fa fw icon={faCopy} />\n            {/if}\n            {#if label}\n                <span class=\"ms-2\">{label}</span>\n            {/if}\n        {/if}\n    </Button>\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/common/CredentialUsedStateBadge.svelte",
    "content": "<script lang=\"ts\">\n    import { uuid } from './sveltestrap-s5-ports/_sveltestrapUtils'\n    import Badge from './sveltestrap-s5-ports/Badge.svelte'\n    import Tooltip from './sveltestrap-s5-ports/Tooltip.svelte'\n\n    interface DatedCredential {\n        lastUsed?: Date\n        dateAdded?: Date\n    }\n\n    export let credential: DatedCredential\n\n    const id = uuid()\n    const lastUseThreshold = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)\n    let badge: HTMLElement | undefined\n</script>\n\n<span bind:this={badge}>\n{#if credential.lastUsed}\n    {#if credential.lastUsed.getTime() < lastUseThreshold.getTime()}\n        <Badge id={id} color=\"warning\">Not used recently</Badge>\n    {:else}\n        <Badge id={id} color=\"success\">Used recently</Badge>\n    {/if}\n{:else}\n    <Badge id={id} color=\"warning\">Never used</Badge>\n{/if}\n</span>\n\n{#if credential.dateAdded || credential.lastUsed}\n    <Tooltip target={badge} animation delay=\"250\">\n        {#if credential.dateAdded}\n            <div>Added on: {new Date(credential.dateAdded).toLocaleString()}</div>\n        {/if}\n        {#if credential.lastUsed}\n            <div>Last used: {new Date(credential.lastUsed).toLocaleString()}</div>\n        {/if}\n    </Tooltip>\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/common/DelayedSpinner.svelte",
    "content": "<script lang=\"ts\">\nimport { Spinner } from '@sveltestrap/sveltestrap'\n\nlet visible = $state(false)\n\nsetTimeout(() => {\n    visible = true\n}, 1000)\n</script>\n\n{#if visible}\n<Spinner style=\"margin: 3rem auto\" />\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/common/EmptyState.svelte",
    "content": "<script lang=\"ts\">\n    export let title: string\n    export let hint = ''\n</script>\n\n<div class=\"empty-state\">\n    <h2>{title}</h2>\n    {#if hint}\n        <p class=\"text-muted\">{hint}</p>\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .empty-state {\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        justify-content: center;\n        width: 50%;\n        min-width: 200px;\n        text-align: center;\n        margin: 4rem auto;\n        opacity: .5;\n    }\n\n    h2 {\n        font-family: 'Poppins';\n    }\n\n    p {\n        font-size: .9rem;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/GettingStarted.svelte",
    "content": "<script lang=\"ts\">\n    import { faCircle } from '@fortawesome/free-regular-svg-icons'\n    import { faCircleCheck, faExternalLink } from '@fortawesome/free-solid-svg-icons'\n    import { ListGroup } from '@sveltestrap/sveltestrap'\n    import type { SetupState } from 'gateway/lib/api'\n    import Fa from 'svelte-fa'\n\n    export let setupState: SetupState\n</script>\n\n<div class=\"getting-started-help border-secondary\">\n    <h2>getting started</h2>\n\n    <ListGroup flush>\n        <!-- eslint-disable-next-line svelte/no-target-blank -->\n        <a href=\"https://warpgate.null.page/docs/\" target=\"_blank\" class=\"list-group-item list-group-item-action d-flex align-items-center\">\n            <Fa icon={faCircle} />\n            <div class=\"item-text me-auto\">\n                <div>Check out the documentation</div>\n            </div>\n            <Fa icon={faExternalLink} />\n        </a>\n\n        <a href=\"/@warpgate/admin#/config/targets/create\" class=\"list-group-item list-group-item-action d-flex align-items-center\">\n            <Fa icon={setupState.hasTargets ? faCircleCheck : faCircle} />\n            <div class=\"item-text\">\n                <div>Add a target</div>\n                <small>Targets are the servers and services that your users will connect to through Warpgate</small>\n            </div>\n        </a>\n\n        <a href=\"/@warpgate/admin#/config/users/create\" class=\"list-group-item list-group-item-action d-flex align-items-center\">\n            <Fa icon={setupState.hasUsers ? faCircleCheck : faCircle} />\n            <div class=\"item-text\">\n                <div>Add a non-admin user</div>\n                <small>Create separate non-admin user accounts for your users</small>\n            </div>\n        </a>\n    </ListGroup>\n</div>\n\n\n<style lang=\"scss\">\n    .getting-started-help {\n        margin-bottom: 3rem;\n        border-top: 1px solid transparent;\n        border-bottom: 1px solid transparent;\n        padding: 1.5rem 0.5rem;\n\n        h2 {\n            font-family: 'Poppins';\n            font-weight: 700;\n            margin-bottom: 1rem;\n        }\n\n        .item-text {\n            margin-left: 1rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/GroupColorCircle.svelte",
    "content": "<script lang=\"ts\">\n    import type { BootstrapThemeColor } from 'gateway/lib/api'\n    import { getCSSColorFromThemeColor } from './helpers'\n\n    export let color: BootstrapThemeColor | ''\n</script>\n\n<div class=\"circle\" style=\"background-color: {getCSSColorFromThemeColor(color || undefined)})\"></div>\n\n<style lang=\"scss\">\n    .circle {\n        display: inline-block;\n        width: 12px;\n        height: 12px;\n        border-radius: 50%;\n        flex-shrink: 0;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/InfoBox.svelte",
    "content": "<script lang=\"ts\">\n    import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'\n    import Fa from 'svelte-fa'\n    import type { Snippet } from 'svelte'\n\n    interface Props {\n        class?: string,\n        children: Snippet,\n    }\n\n    // eslint-disable-next-line svelte/no-unused-props\n    let {\n        children,\n        'class': className = 'mt-3 mb-4',\n    }: Props = $props()\n</script>\n\n<div class=\"text-muted d-flex gap-2 align-items-center {className}\">\n    <Fa icon={faInfoCircle} />\n    <small>\n        {@render children()}\n    </small>\n</div>\n\n\n<style>\n    div {\n        line-height: 1;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/ItemList.svelte",
    "content": "<script lang=\"ts\" module>\n    export interface LoadOptions {\n        search?: string\n        offset: number\n        limit?: number\n    }\n\n    export interface PaginatedResponse<T> {\n        items: T[]\n        offset: number\n        total: number\n    }\n</script>\n\n<script lang=\"ts\" generics=\"T, G = unknown, GK = unknown\">\n    import { Subject, switchMap, map, Observable, distinctUntilChanged, share, combineLatest, tap, debounceTime } from 'rxjs'\n    import Pagination from './Pagination.svelte'\n    import { observe } from 'svelte-observable'\n    import { Input } from '@sveltestrap/sveltestrap'\n    import DelayedSpinner from './DelayedSpinner.svelte'\n    import { onDestroy, onMount, type Snippet } from 'svelte'\n    import EmptyState from './EmptyState.svelte'\n\n    interface Props {\n        page?: number\n        pageSize?: number|undefined\n        load: (_: LoadOptions) => Observable<PaginatedResponse<T>>\n        groupObject?: (_: T) => G\n        groupKey?: (_: G) => GK\n        showSearch?: boolean\n        header?: Snippet<[]>\n        item?: Snippet<[T]>\n        footer?: Snippet<[T[]]>\n        empty?: Snippet<[]>\n        groupHeader?: Snippet<[G]>\n    }\n\n    let {\n        page = $bindable(0),\n        pageSize = undefined,\n        load,\n        showSearch = false,\n        groupObject,\n        groupKey,\n        header,\n        item,\n        footer,\n        empty,\n        groupHeader,\n    }: Props = $props()\n\n    let filter = $state('')\n    let loaded = $state(false)\n\n    const page$ = new Subject<number>()\n    const filter$ = new Subject<string>()\n\n    const responses = combineLatest([\n        page$,\n        filter$.pipe(\n            tap(() => {\n                loaded = false\n            }),\n            debounceTime(200),\n        ),\n    ]).pipe(\n        distinctUntilChanged(),\n        switchMap(([p, f]) => {\n            page = p\n            loaded = false\n            return load({\n                search: f,\n                offset: p * (pageSize ?? 0),\n                limit: pageSize,\n            })\n        }),\n        share(),\n        tap(() => {\n            loaded = true\n        }),\n    )\n\n    const total = observe<number>(responses.pipe(map(x => x.total)), 0)\n    const items = observe<T[]|null>(responses.pipe(map(x => x.items)), null)\n\n    onMount(() => {\n        if (groupHeader && (!groupObject || !groupKey)) {\n            throw new Error('groupObject and groupKey must be provided when using groupHeader')\n        }\n    })\n\n    onDestroy(() => {\n        page$.complete()\n        filter$.complete()\n    })\n\n    $effect(() => {\n        page$.next(page)\n    })\n    $effect(() => {\n        filter$.next(filter)\n    })\n\n    filter$.subscribe(() => {\n        page = 0\n    })\n</script>\n\n{#await $items}\n    <DelayedSpinner />\n{:then _items}\n    <div class=\"d-flex mb-2\" hidden={!loaded}>\n        <!-- either filtering or not filtering and there are at least some items at all -->\n        {#if showSearch && (filter || !!_items?.length)}\n            <Input bind:value={filter} placeholder=\"Search...\" class=\"flex-grow-1 border-0\" />\n        {/if}\n        {@render header?.()}\n    </div>\n    {#if _items}\n        <div class=\"list-group list-group-flush mb-3\">\n            {#each _items as _item, _index (_item)}\n                {#if groupHeader}\n                    {#if _index === 0 || groupKey!(groupObject!(_item)) !== groupKey!(groupObject!(_items[_index - 1]!))}\n                        {@render groupHeader(groupObject!(_item))}\n                    {/if}\n                {/if}\n                {@render item?.(_item)}\n            {/each}\n        </div>\n        {@render footer?.(_items)}\n    {:else}\n        <DelayedSpinner />\n    {/if}\n\n    {#if loaded && !_items?.length}\n        {#if filter}\n            <EmptyState title=\"Nothing found\" />\n        {:else}\n            {@render empty?.()}\n        {/if}\n    {/if}\n{/await}\n\n{#await $total then _total}\n    {#if pageSize && _total > pageSize}\n        <Pagination total={_total} bind:page={page} pageSize={pageSize} />\n    {/if}\n{/await}\n\n<style lang=\"scss\">\n    .list-group:empty {\n        display: none;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/Loadable.svelte",
    "content": "<script lang=\"ts\" generics=\"T\">\n    import type { Snippet } from 'svelte'\n    import Alert from './sveltestrap-s5-ports/Alert.svelte'\n    import DelayedSpinner from './DelayedSpinner.svelte'\n    import { stringifyError } from './errors'\n\n    let { promise, children, data = $bindable() }: {\n        promise: Promise<T>\n        data?: T,\n        children: Snippet<[T]>\n    } = $props()\n\n    let loaded = $state(false)\n    let error: string | undefined = $state()\n\n    $effect(() => {\n        loaded = false\n        data = undefined\n        promise.then(d => {\n            data = d\n        }).catch(err => {\n            stringifyError(err).then(e => {\n                error = e\n            })\n        }).finally(() => {\n            loaded = true\n        })\n    })\n\n</script>\n\n{#if !loaded}\n    <DelayedSpinner />\n{:else}\n    {#if !error}\n        {@render children?.(data!)}\n    {:else}\n        <Alert color=\"danger\">\n            {error}\n        </Alert>\n    {/if}\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/common/NavListItem.svelte",
    "content": "<script lang=\"ts\">\n    import { faArrowRight } from '@fortawesome/free-solid-svg-icons'\n    import Fa from 'svelte-fa'\n    import { link } from 'svelte-spa-router'\n    import active from 'svelte-spa-router/active'\n    import { classnames } from './sveltestrap-s5-ports/_sveltestrapUtils'\n    import type { Snippet } from 'svelte'\n\n    interface Props {\n        class?: string,\n        title?: string,\n        titleSnippet?: Snippet<[]>,\n        description?: string,\n        descriptionSnippet?: Snippet<[]>,\n        addonSnippet?: Snippet<[]>,\n        href: string,\n        small?: boolean,\n    }\n\n    let {\n        title,\n        titleSnippet,\n        'class': className,\n        description,\n        descriptionSnippet,\n        addonSnippet,\n        href,\n        small,\n    }: Props = $props()\n\n    let classes = $derived(classnames(\n        className,\n        'link',\n        small ? 'sm' : false,\n    ))\n</script>\n\n<a\n    class={classes}\n    href={href}\n    use:link\n    use:active\n>\n    <div class=\"text\">\n        <div class=\"title\">\n            {#if titleSnippet}\n                {@render titleSnippet()}\n            {:else}\n                {title}\n            {/if}\n        </div>\n        <div class=\"description text-muted\">\n            {#if descriptionSnippet}\n                {@render descriptionSnippet()}\n            {:else if description}\n                {description}\n            {/if}\n        </div>\n    </div>\n    {@render addonSnippet?.()}\n    <div class=\"icon\">\n        <Fa class=\"icon\" icon={faArrowRight} />\n    </div>\n</a>\n\n\n<style lang=\"scss\">\n    a {\n        cursor: pointer;\n        display: flex;\n        width: 100%;\n        text-decoration: none;\n        padding: 0.8rem 1.5rem 1rem;\n        border-radius: var(--bs-border-radius);\n        align-items: center;\n        gap: 1rem;\n\n        .text {\n            flex-grow: 1;\n        }\n\n        &:hover, &.active {\n            background: var(--bs-list-group-action-hover-bg);\n            .title {\n                color: var(--bs-list-group-action-hover-color);\n            }\n        }\n\n        &:active {\n            background: var(--bs-list-group-action-active-bg);\n            .title {\n                color: var(--bs-list-group-action-active-color);\n            }\n        }\n\n        .title {\n            margin-bottom: 0.25rem;\n            font-size: 1.25rem;\n\n            text-decoration: underline;\n            text-decoration-color: var(--wg-link-underline-color);\n            text-underline-offset: 2px;\n        }\n\n        &.link:hover .title {\n            text-decoration-color: var(--wg-link-hover-underline-color);\n        }\n\n        .description {\n            text-decoration: none;\n            line-height: 1rem;\n            font-size: 0.9rem;\n        }\n\n        &.sm {\n            padding: 0.5rem 1rem;\n\n            .title {\n                font-size: 1rem;\n            }\n\n            .description {\n                font-size: 0.8rem;\n            }\n\n            .icon {\n                display: none;\n            }\n        }\n    }\n\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/Pagination.svelte",
    "content": "<script lang=\"ts\">\n    import { faAngleLeft, faAngleRight } from '@fortawesome/free-solid-svg-icons'\n    import Fa from 'svelte-fa'\n    import { Pagination, PaginationItem, PaginationLink } from '@sveltestrap/sveltestrap'\n\n    interface Props {\n        page?: number;\n        pageSize?: number;\n        total?: number;\n    }\n\n    let { page = $bindable(0), pageSize = 1, total = 1 }: Props = $props()\n\n    let pages: (number|null)[] = $derived.by(() => {\n        let i = 0\n        let result = []\n        let totalPages = Math.floor((total - 1) / pageSize + 1)\n        while (i < totalPages) {\n            if (i < 2 || i > totalPages - 3 || Math.abs(i - page) < 3) {\n                result.push(i)\n            } else if (result[result.length - 1]) {\n                result.push(null)\n            }\n            i++\n        }\n        return result\n    })\n</script>\n\n<Pagination>\n    <PaginationItem disabled={page === 0}>\n        <PaginationLink on:click={() => page--} href=\"#\">\n            <Fa icon={faAngleLeft} />\n        </PaginationLink>\n    </PaginationItem>\n    {#each pages as i (i)}\n        {#if i !== null}\n            <PaginationItem active={page === i}>\n                <PaginationLink on:click={() => page = i} href=\"#\">{i + 1}</PaginationLink>\n            </PaginationItem>\n        {:else}\n            <PaginationItem disabled>\n                <PaginationLink href=\"#\">...</PaginationLink>\n            </PaginationItem>\n        {/if}\n    {/each}\n    <PaginationItem disabled={(page + 1) * pageSize >= total}>\n        <PaginationLink on:click={() => page++} href=\"#\">\n            <Fa icon={faAngleRight} />\n        </PaginationLink>\n    </PaginationItem>\n</Pagination>\n"
  },
  {
    "path": "warpgate-web/src/common/RadioButton.svelte",
    "content": "<script lang=\"ts\" generics=\"T\">\n    import { Button, Input } from '@sveltestrap/sveltestrap'\n    import { classnames } from './sveltestrap-s5-ports/_sveltestrapUtils'\n\n    type Props = {\n        group: T,\n        value: T,\n        label: string,\n    } & Button['$$prop_def']\n\n    let {\n        group = $bindable(),\n        value,\n        label,\n        ...rest\n    }: Props = $props()\n\n    let classes = $derived(classnames(\n        'btn-radio-button',\n        group === value ? 'active': false,\n    ))\n</script>\n\n<Button on:click={e => {\n    group = value\n    e.preventDefault()\n}} class={classes} {...rest}>\n    <Input\n        {label}\n        type=\"radio\"\n        {value}\n        bind:group={group}\n        on:click={e => e.preventDefault()}\n    />\n</Button>\n\n\n<style lang=\"scss\">\n    :global .btn-radio-button {\n        text-align: left;\n\n        label {\n            margin-left: .75rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/common/RateLimitInput.svelte",
    "content": "<script lang=\"ts\">\n    import { Input, InputGroup } from '@sveltestrap/sveltestrap'\n\n    type Props = {\n        change?: CallableFunction\n        value: number | undefined\n        placeholder?: string\n        allowEmpty?: boolean\n    } & Input['$$prop_def']\n\n    let {\n        value = $bindable(),\n        change,\n        placeholder,\n        allowEmpty = true,\n        ...rest\n    }: Props = $props()\n\n    // svelte-ignore state_referenced_locally\n    if (allowEmpty) {\n        placeholder ??= 'Unlimited'\n    }\n\n    // Validation constants\n    const minBytes = 100 * 1000 // 100 KB\n    const maxBytes = 4 * 1000 * 1000 * 1000 // actually 4 GiB (u32) but here 4GB for nicer display\n\n    // Unit conversion constants\n    const units = [\n        { label: 'KB', value: 1000, suffix: 'kilobytes' },\n        { label: 'MB', value: 1000 * 1000, suffix: 'megabytes' },\n        { label: 'GB', value: 1000 * 1000 * 1000, suffix: 'gigabytes' },\n    ]\n\n    // Internal state - these are completely separate from the external value\n    let displayValue: number | undefined = $state()\n    let selectedUnit = $state(units[0]!)\n    let lastExternalValue: number | undefined = $state()\n\n    function isValidValue (v: number | undefined): boolean {\n        if (v === undefined) {\n            return allowEmpty\n        }\n        return v >= minBytes && v <= maxBytes\n    }\n\n    // Validation logic\n    const isValid = $derived.by(() => isValidValue(toBytes()))\n\n    // Generate feedback message\n    const feedbackMessage = $derived.by(() => {\n        const minUnit = getDisplayUnit(minBytes)\n        const maxUnit = getDisplayUnit(maxBytes)\n        const minDisplay = (minBytes / minUnit.value).toFixed(0)\n        const maxDisplay = (maxBytes / maxUnit.value).toFixed(0)\n\n        let msg = `Value must be between ${minDisplay} ${minUnit.label} and ${maxDisplay} ${maxUnit.label}.`\n        if (allowEmpty) {\n            msg += ' Leave empty for no limit.'\n        }\n        return msg\n    })\n\n    // Helper function to get best display unit for a byte value\n    function getDisplayUnit(bytes: number) {\n        for (let i = units.length - 1; i >= 0; i--) {\n            const unit = units[i]!\n            if (bytes >= unit.value) {\n                return unit\n            }\n        }\n        return units[0]!\n    }\n\n    // Initialize display when external value changes (not internal changes)\n    $effect(() => {\n        // Only update if the value actually changed from outside\n        if (value !== lastExternalValue) {\n            lastExternalValue = value\n            if (value !== undefined && value !== null) {\n                // Auto-select best unit using helper function\n                selectedUnit = getDisplayUnit(value)\n                displayValue = value / selectedUnit.value\n            } else {\n                displayValue = undefined\n                selectedUnit = units[0]!\n            }\n        }\n    })\n\n    // Convert display value to bytes\n    function toBytes(): number | undefined {\n        if (displayValue === undefined || displayValue === null) {\n            return undefined\n        }\n        return Math.round(displayValue * selectedUnit.value)\n    }\n\n    function handleChange() {\n        maybeUpdateValue(toBytes())\n    }\n\n    function maybeUpdateValue (v: number | undefined) {\n        if (!isValidValue(v)) {\n            return\n        }\n        value = v\n        lastExternalValue = v // Prevent effect from re-running\n        change?.()\n    }\n</script>\n\n<InputGroup>\n    <Input\n        {...rest}\n        type=\"number\"\n        min=\"0\"\n        step=\"any\"\n        bind:value={displayValue}\n        on:change={handleChange}\n        {placeholder}\n        invalid={!isValid}\n    />\n    <Input\n        type=\"select\"\n        class=\"form-select\"\n        feedback={feedbackMessage}\n        bind:value={selectedUnit}\n        onchange={handleChange}\n        style=\"max-width: 100px;\"\n    >\n        {#each units as unit (unit.value)}\n            <option value={unit}>{unit.label}/s</option>\n        {/each}\n    </Input>\n</InputGroup>\n"
  },
  {
    "path": "warpgate-web/src/common/ThemeSwitcher.svelte",
    "content": "<script lang=\"ts\">\n    import Fa from 'svelte-fa'\n    import { faCloudSun, faMoon, faSun } from '@fortawesome/free-solid-svg-icons'\n    import { Button } from '@sveltestrap/sveltestrap'\n    import { get } from 'svelte/store'\n\n    import { currentTheme, setCurrentTheme } from 'theme'\n    import Tooltip from './sveltestrap-s5-ports/Tooltip.svelte'\n\n    function toggle () {\n        if (get(currentTheme) === 'auto') {\n            setCurrentTheme('dark')\n        } else if (get(currentTheme) === 'dark') {\n            setCurrentTheme('light')\n        } else {\n            setCurrentTheme('auto')\n        }\n    }\n</script>\n\n<Button color=\"link\" on:click={toggle} id=\"button\" title=\"Switch theme\">\n    {#if $currentTheme === 'dark'}\n    <Fa fw icon={faMoon} />\n    {:else if $currentTheme === 'light'}\n    <Fa fw icon={faSun} />\n    {:else}\n    <Fa fw icon={faCloudSun} />\n    {/if}\n</Button>\n<Tooltip target=\"button\" animation>\n    {#if $currentTheme === 'dark'}\n    Dark theme\n    {:else if $currentTheme === 'light'}\n    Light theme\n    {:else}\n    Automatic theme\n    {/if}\n</Tooltip>\n"
  },
  {
    "path": "warpgate-web/src/common/autosave.ts",
    "content": "import { BehaviorSubject } from 'rxjs'\nimport { get, writable, type Writable } from 'svelte/store'\n\nexport function autosave<T> (key: string, initial: T): ([Writable<T>, BehaviorSubject<T>]) {\n    key = `warpgate:${key}`\n    const v = writable(JSON.parse(localStorage.getItem(key) ?? JSON.stringify(initial)))\n    const v$ = new BehaviorSubject<T>(get(v))\n    v.subscribe(value => {\n        localStorage.setItem(key, JSON.stringify(value))\n        v$.next(value)\n    })\n    return [v, v$]\n}\n"
  },
  {
    "path": "warpgate-web/src/common/errors.ts",
    "content": "import * as admin from 'admin/lib/api'\nimport * as gw from 'gateway/lib/api'\n\n// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types\nexport async function stringifyError (err: any): Promise<string> {\n    if (err instanceof gw.ResponseError) {\n        return gw.stringifyError(err)\n    }\n    if (err instanceof admin.ResponseError) {\n        return admin.stringifyError(err)\n    }\n    return err.toString()\n}\n"
  },
  {
    "path": "warpgate-web/src/common/helpers.ts",
    "content": "import { type BootstrapThemeColor } from 'gateway/lib/api'\n\nexport function getCSSColorFromThemeColor(color?: BootstrapThemeColor): string {\n    // Handle capitalized color names from API (e.g., \"Primary\" -> \"primary\")\n    const colorLower = (color ?? 'Secondary').toLowerCase()\n    return `var(--bs-${colorLower});`\n}\n"
  },
  {
    "path": "warpgate-web/src/common/protocols.ts",
    "content": "import { shellEscape } from 'gateway/lib/shellEscape'\nimport type { Info } from 'gateway/lib/api'\nimport { CredentialKind } from 'admin/lib/api'\n\nexport interface ConnectionOptions {\n    targetName?: string\n    username?: string\n    serverInfo?: Info\n    targetExternalHost?: string\n    ticketSecret?: string\n    targetDefaultDatabaseName?: string\n}\n\nexport function makeSSHUsername (opt: ConnectionOptions): string {\n    if (opt.ticketSecret) {\n        return `ticket-${opt.ticketSecret}`\n    }\n    return `${opt.username ?? 'username'}:${opt.targetName ?? 'target'}`\n}\n\nexport function makeExampleSSHCommand (opt: ConnectionOptions): string {\n    return shellEscape([\n        'ssh',\n        `${makeSSHUsername(opt)}@${opt.serverInfo?.externalHost ?? 'warpgate-host'}`,\n        '-p',\n        (opt.serverInfo?.ports.ssh ?? 'warpgate-ssh-port').toString(),\n    ])\n}\n\nexport function makeExampleSCPCommand (opt: ConnectionOptions): string {\n    return shellEscape([\n        'scp',\n        '-o',\n        `User=\"${makeSSHUsername(opt)}\"`,\n        '-P',\n        (opt.serverInfo?.ports.ssh ?? 'warpgate-ssh-port').toString(),\n        'local-file',\n        `${opt.serverInfo?.externalHost ?? 'warpgate-host'}:remote-file`,\n    ])\n}\n\nexport function makeMySQLUsername (opt: ConnectionOptions): string {\n    if (opt.ticketSecret) {\n        return `ticket-${opt.ticketSecret}`\n    }\n    return `${opt.username ?? 'username'}#${opt.targetName ?? 'target'}`\n}\n\nexport function makeExampleMySQLCommand (opt: ConnectionOptions): string {\n    const dbName = opt.targetDefaultDatabaseName?.trim() || 'database-name'\n    let cmd = shellEscape(['mysql', '-u', makeMySQLUsername(opt), '--host', opt.serverInfo?.externalHost ?? 'warpgate-host', '--port', (opt.serverInfo?.ports.mysql ?? 'warpgate-mysql-port').toString(), '--ssl', dbName])\n    if (!opt.ticketSecret) {\n        cmd += ' -p'\n    }\n    return cmd\n}\n\nexport function makeExampleMySQLURI (opt: ConnectionOptions): string {\n    const pwSuffix = opt.ticketSecret ? '' : ':<password>'\n    const dbName = opt.targetDefaultDatabaseName?.trim() || 'database-name'\n    return `mysql://${makeMySQLUsername(opt)}${pwSuffix}@${opt.serverInfo?.externalHost ?? 'warpgate-host'}:${opt.serverInfo?.ports.mysql ?? 'warpgate-mysql-port'}/${dbName}?sslMode=required`\n}\n\nexport const makePostgreSQLUsername = makeMySQLUsername\n\nexport function makeExamplePostgreSQLCommand (opt: ConnectionOptions): string {\n    const dbName = opt.targetDefaultDatabaseName?.trim() || 'database-name'\n    const args = ['psql', '-U', makeMySQLUsername(opt), '--host', opt.serverInfo?.externalHost ?? 'warpgate-host', '--port', (opt.serverInfo?.ports.postgres ?? 'warpgate-postgres-port').toString()]\n    if (!opt.ticketSecret) {\n        args.push('-W')\n    }\n    args.push(dbName)\n    return shellEscape(args)\n}\n\nexport function makeExamplePostgreSQLURI (opt: ConnectionOptions): string {\n    const pwSuffix = opt.ticketSecret ? '' : ':<password>'\n    const dbName = opt.targetDefaultDatabaseName?.trim() || 'database-name'\n    return `postgresql://${makePostgreSQLUsername(opt)}${pwSuffix}@${opt.serverInfo?.externalHost ?? 'warpgate-host'}:${opt.serverInfo?.ports.postgres ?? 'warpgate-postgres-port'}/${dbName}?sslmode=require`\n}\n\nexport function makeTargetURL (opt: ConnectionOptions): string {\n    const host = opt.targetExternalHost ? `${opt.targetExternalHost}:${opt.serverInfo?.ports.http ?? 443}` : location.host\n    if (opt.ticketSecret) {\n        return `${location.protocol}//${host}/?warpgate-ticket=${opt.ticketSecret}`\n    }\n    return `${location.protocol}//${host}/?warpgate-target=${opt.targetName}`\n}\n\nexport const possibleCredentials: Record<string, Set<CredentialKind>> = {\n    ssh: new Set([CredentialKind.Password, CredentialKind.PublicKey, CredentialKind.Totp, CredentialKind.WebUserApproval]),\n    http: new Set([CredentialKind.Password, CredentialKind.Totp, CredentialKind.Sso]),\n    mysql: new Set([CredentialKind.Password]),\n    postgres: new Set([CredentialKind.Password, CredentialKind.WebUserApproval]),\n    kubernetes: new Set([CredentialKind.Certificate, CredentialKind.WebUserApproval]),\n}\n\nexport function abbreviatePublicKey (key: string): string {\n    return key.slice(0, 16) + '...' + key.slice(-8)\n}\n\nexport function makeKubernetesContext (opt: ConnectionOptions): string {\n    if (opt.ticketSecret) {\n        return `ticket-${opt.ticketSecret}`\n    }\n    return `${opt.username ?? 'username'}:${opt.targetName ?? 'target'}`\n}\n\nexport function makeKubernetesNamespace (_opt: ConnectionOptions): string {\n    return 'default'\n}\n\nexport function makeKubernetesClusterUrl (opt: ConnectionOptions): string {\n    const baseUrl = `https://${opt.serverInfo?.externalHost ?? 'warpgate-host'}:${opt.serverInfo?.ports.kubernetes ?? 'warpgate-kubernetes-port'}`\n    return `${baseUrl}/${encodeURIComponent(opt.targetName ?? 'target')}`\n}\n\nexport function makeKubeconfig (opt: ConnectionOptions): string {\n    const clusterUrl = makeKubernetesClusterUrl(opt)\n    const context = makeKubernetesContext(opt)\n    const namespace = makeKubernetesNamespace(opt)\n\n    if (opt.ticketSecret) {\n        // Token-based authentication using API ticket\n        return `apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n    server: ${clusterUrl}\n    insecure-skip-tls-verify: true\n  name: warpgate-${opt.targetName ?? 'target'}\ncontexts:\n- context:\n    cluster: warpgate-${opt.targetName ?? 'target'}\n    namespace: ${namespace}\n    user: ${context}\n  name: ${context}\ncurrent-context: ${context}\nusers:\n- name: ${context}\n  user:\n    token: ${opt.ticketSecret}\n`\n    } else {\n        // Certificate-based authentication\n        return `apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n    server: ${clusterUrl}\n    insecure-skip-tls-verify: true\n  name: warpgate-${opt.targetName ?? 'target'}\ncontexts:\n- context:\n    cluster: warpgate-${opt.targetName ?? 'target'}\n    namespace: ${namespace}\n    user: ${context}\n  name: ${context}\ncurrent-context: ${context}\nusers:\n- name: ${context}\n  user:\n    client-certificate-data: <your-client-certificate-base64>\n    client-key-data: <your-private-key-base64>\n`\n    }\n}\n\nexport function makeExampleKubectlCommand (_opt: ConnectionOptions): string {\n    return shellEscape(['kubectl', '--kubeconfig', 'warpgate-kubeconfig.yaml', 'get', 'pods'])\n}\n\n\nexport interface ProtocolProperties {\n    sessionsCanBeClosed: boolean\n}\n\nexport const PROTOCOL_PROPERTIES: Record<string, ProtocolProperties> = {\n    ssh: { sessionsCanBeClosed: true },\n    http: { sessionsCanBeClosed: true },\n    mysql: { sessionsCanBeClosed: true },\n    postgres: { sessionsCanBeClosed: true },\n    kubernetes: { sessionsCanBeClosed: false },\n}\n"
  },
  {
    "path": "warpgate-web/src/common/recordings.ts",
    "content": "import { type Recording } from 'admin/lib/api'\n\nexport type RecordingMetadata ={\n    type: 'kubernetes-exec',\n    namespace: string\n    pod: string\n    container: string\n    command: string\n} | {\n    type: 'kubernetes-attach',\n    namespace: string\n    pod: string\n    container: string\n} | {\n    type: 'kubernetes-api',\n} | {\n    type: 'ssh-shell',\n    channel: number\n} | {\n    type: 'ssh-exec',\n    channel: number\n} | {\n    type: 'ssh-direct-tcpip',\n    host: string\n    port: number\n} | {\n    type: 'ssh-direct-socket',\n    path: string\n} | {\n    type: 'ssh-forwarded-tcpip',\n    host: string\n    port: number\n} | {\n    type: 'ssh-forwarded-socket',\n    path: string\n}\n\n\nexport function recordingMetadataToFieldSet(metadata: RecordingMetadata): [string, string][] {\n    const fieldSets: [string, string][] = []\n\n    switch (metadata.type) {\n        case 'kubernetes-exec':\n            fieldSets.push(['Namespace', metadata.namespace])\n            fieldSets.push(['Pod', metadata.pod])\n            fieldSets.push(['Container', metadata.container])\n            fieldSets.push(['Command', metadata.command])\n            break\n        case 'kubernetes-attach':\n            fieldSets.push(['Namespace', metadata.namespace])\n            fieldSets.push(['Pod', metadata.pod])\n            fieldSets.push(['Container', metadata.container])\n            break\n        case 'ssh-shell':\n            fieldSets.push(['Channel', metadata.channel.toString()])\n            break\n        case 'ssh-exec':\n            fieldSets.push(['Channel', metadata.channel.toString()])\n            break\n        case 'ssh-direct-tcpip':\n        case 'ssh-forwarded-tcpip':\n            fieldSets.push(['Host', metadata.host])\n            fieldSets.push(['Port', metadata.port.toString()])\n            break\n        case 'ssh-direct-socket':\n        case 'ssh-forwarded-socket':\n            fieldSets.push(['Path', metadata.path])\n            break\n    }\n\n    return fieldSets\n}\n\nexport function recordingTypeLabel(recording: Recording): string {\n    const metadata = JSON.parse(recording.metadata) as RecordingMetadata | null\n    switch (metadata?.type) {\n        case 'kubernetes-api':\n            return 'API'\n        case 'kubernetes-exec':\n            return 'Exec'\n        case 'kubernetes-attach':\n            return 'Attach'\n        case 'ssh-shell':\n            return 'Shell'\n        case 'ssh-exec':\n            return 'Exec'\n        case 'ssh-direct-tcpip':\n            return 'Local TCP forwarding'\n        case 'ssh-direct-socket':\n            return 'Local UNIX socket forwarding'\n        case 'ssh-forwarded-tcpip':\n            return 'Remote TCP forwarding'\n        case 'ssh-forwarded-socket':\n            return 'Remote UNIX socket forwarding'\n    }\n\n    return 'Unknown type'\n}\n"
  },
  {
    "path": "warpgate-web/src/common/sveltestrap-s5-ports/Alert.svelte",
    "content": "<script lang=\"ts\">\n    // Copied from Sveltestrap and tweaked for S5 compatibility\n\n    import { fade as fadeTransition } from 'svelte/transition'\n    import { classnames } from './_sveltestrapUtils'\n\n    /**\n    * Additional CSS classes for container element.\n    * @type {string}\n    * @default ''\n    */\n\n    interface Props {\n        class?: string;\n        closeAriaLabel?: string;\n        closeClassName?: string;\n        color?: string;\n        dismissible?: boolean;\n        fade?: boolean;\n        heading?: string;\n        isOpen?: boolean;\n        toggle?: CallableFunction;\n        theme?: string | undefined;\n        transition?: object;\n        headingSlot?: import('svelte').Snippet;\n        children: () => any\n        [key: string]: any\n    }\n\n    let {\n        'class': className = '',\n        closeAriaLabel = 'Close',\n        closeClassName = '',\n        color = 'success',\n        dismissible = false,\n        fade = true,\n        heading = '',\n        isOpen = $bindable(true),\n        toggle = undefined,\n        theme = undefined,\n        transition = { duration: fade ? 400 : 0 },\n        headingSlot,\n        children,\n        ...rest\n    }: Props = $props()\n\n    let showClose = $derived(dismissible || toggle)\n    let handleToggle = $derived(toggle ?? (() => (isOpen = false)))\n    let classes = $derived(classnames(className, 'alert', `alert-${color}`, {\n        'alert-dismissible': showClose,\n    }))\n    let closeClassNames = $derived(classnames('btn-close', closeClassName))\n</script>\n\n{#if isOpen}\n<div {...rest} data-bs-theme={theme} transition:fadeTransition={transition} class={classes} role=\"alert\">\n    {#if heading || headingSlot}\n    <h4 class=\"alert-heading\">\n        {heading}{@render headingSlot?.()}\n    </h4>\n    {/if}\n    {#if showClose}\n    <button type=\"button\" class={closeClassNames} aria-label={closeAriaLabel} onclick={handleToggle as any}></button>\n    {/if}\n    {@render children?.()}\n</div>\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/common/sveltestrap-s5-ports/Badge.svelte",
    "content": "<script>\n    import { classnames } from './_sveltestrapUtils'\n\n\n    /**\n    * Additional CSS classes for container element.\n    * @type {string}\n    * @default ''\n    */\n\n\n    /**\n     * @typedef {Object} Props\n     * @property {string} [ariaLabel] - Text to be read by screen readers.\n     * @property {boolean | string} [border] - Determines if the badge should have a border\n     * @property {string} [class]\n     * @property {string} [children] - The content to be displayed within the badge.\n     * @property {string} [color] - The color theme for the badge.\n     * @property {string} [href] - The href attribute for the badge, which turns it into a link if provided.\n     * @property {boolean} [indicator] - Create a circular indicator for absolute positioned badge.\n     * @property {boolean} [pill] - Flag to indicate if the badge should have a pill shape.\n     * @property {boolean} [positioned] - Flag to indicate if the badge should be absolutely positioned.\n     * @property {string} [placement] - Classes determining where the badge should be absolutely positioned.\n     * @property {boolean | string} [shadow] - Determines if the badge should have a shadow\n     * @property {string | undefined} [theme] - The theme name override to apply to this component instance.\n     * @property {CallableFunction} [children]\n     */\n\n    let {\n        ariaLabel = '',\n        border = false,\n        'class': className = '',\n        color = 'secondary',\n        href = '',\n        indicator = false,\n        pill = false,\n        positioned = false,\n        placement = 'top-0 start-100',\n        shadow = false,\n        theme = undefined,\n        children,\n        ...rest\n    } = $props()\n\n    let classes = $derived(classnames(\n        'badge',\n        `text-bg-${color}`,\n        pill ? 'rounded-pill' : false,\n        positioned ? 'position-absolute translate-middle' : false,\n        positioned ? placement : false,\n        indicator ? 'p-2' : false,\n        border ? (typeof border === 'string' ? border : 'border') : false,\n        shadow ? (typeof shadow === 'string' ? shadow : 'shadow') : false,\n        className\n    ))\n</script>\n\n{#if href}\n<a {...rest} {href} class={classes} data-bs-theme={theme}>\n    {@render children?.()}\n    {#if positioned || indicator}\n    <span class=\"visually-hidden\">{ariaLabel}</span>\n    {/if}\n</a>\n{:else}\n<span {...rest} class={classes} data-bs-theme={theme}>\n    {@render children?.()}\n    {#if positioned || indicator}\n    <span class=\"visually-hidden\">{ariaLabel}</span>\n    {/if}\n</span>\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/common/sveltestrap-s5-ports/ModalHeader.svelte",
    "content": "<script>\n    import { classnames } from './_sveltestrapUtils'\n\n    /**\n    * @typedef {Object} Props\n    * @property {string} [class]\n    * @property {boolean | CallableFunction | undefined} [toggle] - Determines whether the modal header includes a close button.\n    * @property {string} [closeAriaLabel] - The aria-label for the close button.\n    * @property {string} [id] - The unique id of the modal header.\n    * @property {any} [children]\n    * @property {import('svelte').Snippet} [children]\n    * @property {import('svelte').Snippet} [close]\n    */\n\n    /** @type {Props & { [key: string]: any }} */\n    let {\n        'class': className = '',\n        toggle = undefined,\n        closeAriaLabel = 'Close',\n        children,\n        close,\n    } = $props()\n\n    let classes = $derived(classnames(className, 'modal-header'))\n</script>\n\n<div class={classes}>\n    <h5 class=\"modal-title\">\n        {@render children()}\n    </h5>\n    {#if close}{@render close()}{:else}\n    {#if typeof toggle === 'function'}\n    <button type=\"button\" onclick={() => toggle()} class=\"btn-close\" aria-label={closeAriaLabel}></button>\n    {/if}\n    {/if}\n</div>\n"
  },
  {
    "path": "warpgate-web/src/common/sveltestrap-s5-ports/Tooltip.svelte",
    "content": "<script module lang=\"ts\">\n    /** eslint-disable @typescript-eslint/ban-ts-comment */\n</script>\n<script lang=\"ts\">\n    import { run } from 'svelte/legacy'\n\n    import { onDestroy, onMount } from 'svelte'\n    import { createPopper } from '@popperjs/core'\n    import { classnames, uuid } from './_sveltestrapUtils'\n    import { Portal, InlineContainer } from '@sveltestrap/sveltestrap'\n\n    interface Props {\n        class?: string\n        animation?: boolean\n        container?: string\n        id?: string\n        isOpen?: boolean\n        placement?: string\n        target?: string | HTMLElement\n        theme?: string | null\n        delay?: string | number\n        children: () => any\n        [key: string]: any\n    }\n\n    let {\n        'class': className = '',\n        animation = true,\n        container = undefined,\n        id = `tooltip_${uuid()}`,\n        isOpen = $bindable(false),\n        placement = 'top',\n        target = '',\n        theme = null,\n        delay = 0,\n        children,\n        ...rest\n    }: Props = $props()\n\n    /**\n    * @type {string}\n    */\n    let bsPlacement: string = $state('')\n    /**\n    * @type {object}\n    */\n    let popperInstance: any = $state()\n    /**\n    * @type {string}\n    */\n    let popperPlacement = $state(placement)\n    /**\n    * @type {any}\n    */\n    let targetEl: any = $state()\n    /**\n    * @type {any}\n    */\n    let tooltipEl: any = $state()\n    /**\n    * @type {any}\n    */\n    let showTimer: any\n\n    const checkPopperPlacement = {\n        name: 'checkPopperPlacement',\n        enabled: true,\n        phase: 'main',\n        fn({ state }: any) {\n            popperPlacement = state.placement\n        },\n    }\n\n\n    const open = () => {\n        clearTimeout(showTimer)\n        showTimer = setTimeout(() => (isOpen = true), delay as any)\n    }\n\n    const close = () => {\n        clearTimeout(showTimer)\n        isOpen = false\n    }\n\n    onMount(registerEventListeners)\n\n    onDestroy(() => {\n        unregisterEventListeners()\n        clearTimeout(showTimer)\n    })\n\n\n    function registerEventListeners() {\n\n        if (target == null || !target) {\n            targetEl = null\n            return\n        }\n\n        // Check if target is HTMLElement\n        try {\n            if (target instanceof HTMLElement) {\n                targetEl = target\n            }\n        } catch {\n            // fails on SSR\n        }\n\n        // If targetEl has not been found yet\n\n        if (targetEl == null) {\n            // Check if target can be found via querySelector\n            try {\n                targetEl = document.querySelector(`#${target}`)\n            } catch {\n                // fails on SSR\n            }\n        }\n\n        // If we've found targetEl\n        if (targetEl) {\n            targetEl.addEventListener('mouseover', open)\n            targetEl.addEventListener('mouseleave', close)\n            targetEl.addEventListener('focus', open)\n            targetEl.addEventListener('blur', close)\n        }\n    }\n\n    function unregisterEventListeners() {\n        if (targetEl) {\n            targetEl.removeEventListener('mouseover', open)\n            targetEl.removeEventListener('mouseleave', close)\n            targetEl.removeEventListener('focus', open)\n            targetEl.removeEventListener('blur', close)\n            targetEl.removeAttribute('aria-describedby')\n        }\n    }\n\n    run(() => {\n        if (isOpen && tooltipEl) {\n            popperInstance = createPopper(targetEl, tooltipEl, {\n                placement,\n                modifiers: [checkPopperPlacement],\n            } as any)\n        } else if (popperInstance) {\n            popperInstance.destroy()\n            popperInstance = undefined\n        }\n    })\n    run(() => {\n        if (target) {\n            unregisterEventListeners()\n            registerEventListeners()\n        }\n    })\n    run(() => {\n        if (targetEl) {\n            if (isOpen) {\n                targetEl.setAttribute('aria-describedby', id)\n            } else {\n                targetEl.removeAttribute('aria-describedby')\n            }\n        }\n    })\n    run(() => {\n        if (popperPlacement === 'left') {\n            bsPlacement = 'start'\n        } else if (popperPlacement === 'right') {\n            bsPlacement = 'end'\n        } else {\n            bsPlacement = popperPlacement\n        }\n    })\n    let classes = $derived(classnames(\n        className,\n        'tooltip',\n        `bs-tooltip-${bsPlacement}`,\n        animation ? 'fade' : false,\n        isOpen ? 'show' : false\n    ))\n    let outer = $derived(container === 'inline' ? InlineContainer : Portal)\n</script>\n\n{#if isOpen}\n{@const SvelteComponent = outer}\n<SvelteComponent>\n    <div\n    bind:this={tooltipEl}\n    {...rest}\n    class={classes}\n    {id}\n    role=\"tooltip\"\n    data-bs-theme={theme}\n    data-bs-delay={delay}\n    >\n    <div class=\"tooltip-arrow\" data-popper-arrow></div>\n    <div class=\"tooltip-inner\">\n        {@render children?.()}\n    </div>\n</div>\n</SvelteComponent>\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/common/sveltestrap-s5-ports/_sveltestrapUtils.ts",
    "content": "// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types\nexport function toClassName(value: any) {\n    let result = ''\n\n    if (typeof value === 'string' || typeof value === 'number') {\n        result += value\n    } else if (typeof value === 'object') {\n        if (Array.isArray(value)) {\n            result = value.map(toClassName).filter(Boolean).join(' ')\n        } else {\n            for (const key in value) {\n                if (value[key]) {\n                    // eslint-disable-next-line @typescript-eslint/no-unused-expressions\n                    result && (result += ' ')\n                    result += key\n                }\n            }\n        }\n    }\n\n    return result\n}\n\n// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types\nexport const classnames = (...args: any[]) => args.map(toClassName).filter(Boolean).join(' ')\n\n\nexport function uuid(): string {\n    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n        const r = (Math.random() * 16) | 0\n        const v = c === 'x' ? r : (r & 0x3) | 0x8\n        return v.toString(16)\n    })\n}\n"
  },
  {
    "path": "warpgate-web/src/embed/EmbeddedUI.svelte",
    "content": "<script lang=\"ts\">\nimport { api } from 'gateway/lib/api'\nimport { onMount } from 'svelte'\nimport logo from '../../public/assets/favicon.svg'\n\nlet ready = false\nlet menuVisible = false\nlet dragging = false\nlet savedPosition = { x: 0.1, y: 0.8 }\nlet position = { x: 0.1, y: 0.8 }\nlet dragStartCoords = { x: 0, y: 0 }\nlet externalHost: string | undefined = undefined\n\nif (localStorage.warpgateMenuLocation) {\n    position = JSON.parse(localStorage.warpgateMenuLocation)\n    savedPosition = position\n}\n\nonMount(async () => {\n    ready = true\n    const info = await api.getInfo()\n    externalHost = info.externalHost\n})\n\nfunction drag (e: MouseEvent) {\n    if (!dragging) {\n        return\n    }\n    const { x, y } = dragStartCoords\n    const { clientX, clientY } = e\n    const dx = clientX - x\n    const dy = clientY - y\n    position = {\n        x: Math.max(0, Math.min(1, savedPosition.x + dx / window.innerWidth)),\n        y: Math.max(0, Math.min(1, savedPosition.y + dy / window.innerHeight)),\n    }\n}\n\nfunction startDragging (e: MouseEvent) {\n    dragStartCoords = { x: e.clientX, y: e.clientY }\n    dragging = true\n}\n\nfunction stopDragging () {\n    dragging = false\n    savedPosition = position\n    localStorage.warpgateMenuLocation = JSON.stringify(position)\n}\n\nfunction goHome () {\n    if (externalHost) {\n        location.href = `https://${externalHost}/@warpgate`\n    } else {\n        location.href = '/@warpgate'\n    }\n}\n\nasync function logout () {\n    await api.logout()\n    location.reload()\n}\n</script>\n\n<svelte:window\n    on:mousemove|passive={drag}\n    on:mouseup={() => {\n        menuVisible = false\n        stopDragging()\n    }}\n/>\n\n<div\n    class=\"embedded-ui\"\n    class:wg-hidden={!ready}\n    style=\"left: {position.x * 100}%; top: {position.y * 100}%\"\n>\n    <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->\n    <img\n        class=\"menu-toggle\"\n        src={logo} alt=\"Warpgate\"\n        on:mouseup|stopPropagation|preventDefault={() => {\n            if (!dragging) {\n                menuVisible = !menuVisible\n            } else {\n                stopDragging()\n            }\n        }}\n        on:mousedown|preventDefault\n        on:mousemove|preventDefault={e => {\n            if (e.buttons && !dragging) {\n                startDragging(e)\n            }\n        }}\n    >\n\n    {#if menuVisible}\n        <div class=\"menu\">\n            <button on:mouseup={goHome}>Home</button>\n            <button on:mouseup={logout}>Log out</button>\n        </div>\n    {/if}\n</div>\n\n<style lang=\"scss\">\n    .embedded-ui {\n        position: fixed;\n        z-index: 9999;\n\n        color: #555;\n\n        font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n\n        &.wg-hidden > img.menu-toggle {\n            opacity: 0;\n        }\n\n        > img.menu-toggle {\n            transition: 0.5s ease-out opacity;\n            opacity: 1;\n            cursor: pointer;\n\n            width: 40px;\n            height: 40px;\n\n            border: none;\n            padding: 0;\n        }\n\n        .menu {\n            position: absolute;\n            left: 0;\n            bottom: calc(100% + 10px);\n\n            min-width: 200px;\n            max-height: 50vh;\n            overflow-y: auto;\n\n            border-radius: 7px;\n            border: 1px solid rgba(128, 128, 128, .25);\n            background: rgba(255, 255, 255, .5);\n            backdrop-filter: blur(4px);\n\n            padding: 5px;\n\n            > button {\n                display: flex;\n                align-items: center;\n                width: 100%;\n                padding: 5px 10px;\n\n                background: transparent;\n                border: 0;\n                border-radius: 4px;\n\n                color: rgba(0, 0, 0, .5);\n\n                &:not(:first-child) {\n                    margin-top: 5px;\n                }\n\n                &:hover {\n                    color: #555;\n                    background: rgba(255, 255, 255, .25);\n                }\n            }\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/embed/index.ts",
    "content": "import { api } from 'gateway/lib/api'\nimport EmbeddedUI from './EmbeddedUI.svelte'\n\nexport { }\n\nnavigator.serviceWorker.getRegistrations().then(registrations => {\n    for (const registration of registrations) {\n        registration.unregister()\n    }\n})\n\napi.getInfo().then(info => {\n    console.log(`Warpgate v${info.version}, logged in as ${info.username}`)\n})\n\nconst container = document.createElement('div')\ncontainer.id = 'warpgate-embedded-ui'\ndocument.body.appendChild(container)\n\nsetTimeout(() => new EmbeddedUI({\n    target: container,\n}))\n"
  },
  {
    "path": "warpgate-web/src/gateway/ApiTokenManager.svelte",
    "content": "<script lang=\"ts\">\n    import { api, type ExistingApiToken } from 'gateway/lib/api'\n    import Loadable from 'common/Loadable.svelte'\n    import { faKey } from '@fortawesome/free-solid-svg-icons'\n    import Fa from 'svelte-fa'\n    import CreateApiTokenModal from './CreateApiTokenModal.svelte'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import CopyButton from 'common/CopyButton.svelte'\n    import Badge from 'common/sveltestrap-s5-ports/Badge.svelte'\n    import EmptyState from 'common/EmptyState.svelte'\n    import { Button } from '@sveltestrap/sveltestrap'\n\n    let tokens: ExistingApiToken[] = $state([])\n    let creatingToken = $state(false)\n    let lastCreatedSecret: string | undefined = $state()\n    const now = Date.now()\n\n    async function deleteToken (token: ExistingApiToken) {\n        tokens = tokens.filter(c => c.id !== token.id)\n        await api.deleteMyApiToken(token)\n        lastCreatedSecret = undefined\n    }\n\n    async function createToken (label: string, expiry: Date) {\n        const { secret, token } = await api.createApiToken({ newApiToken : { label, expiry } })\n        lastCreatedSecret = secret\n        tokens = [...tokens, token]\n    }\n</script>\n\n<div class=\"page-summary-bar mt-4\">\n    <h1>API tokens</h1>\n    <Button color=\"primary\" class=\"ms-auto\" onclick={e => {\n        creatingToken = true\n        e.preventDefault()\n    }}>Create token</Button>\n</div>\n\n{#if lastCreatedSecret}\n<Alert color=\"info\">\n    <div>Your token - shown only once:</div>\n    <div class=\"d-flex align-items-center mt-2\">\n        <code style=\"min-width: 0\">{lastCreatedSecret}</code>\n        <CopyButton class=\"ms-auto\" text={lastCreatedSecret} />\n    </div>\n</Alert>\n{/if}\n\n<Loadable promise={api.getMyApiTokens()} bind:data={tokens}>\n    {#if tokens.length === 0}\n        <EmptyState\n            title=\"No tokens yet\"\n            hint=\"Tokens let you manage Warpgate programmatically via its API\"\n        />\n    {/if}\n\n    <div class=\"list-group list-group-flush mb-3\">\n        {#each tokens as token (token.id)}\n        <div class=\"list-group-item d-flex align-items-center pr-0\">\n            <Fa fw icon={faKey} />\n            <span class=\"label ms-3\">{token.label}</span>\n            {#if token.expiry.getTime() < now}\n                <Badge color=\"danger\" class=\"ms-2\">Expired</Badge>\n            {:else}\n                <Badge color=\"success\" class=\"ms-2\">{token.expiry.toLocaleDateString()}</Badge>\n            {/if}\n            <span class=\"ms-auto\"></span>\n            <Button\n                color=\"link\"\n                class=\"ms-2\"\n                onclick={e => {\n                    deleteToken(token)\n                    e.preventDefault()\n                }}\n            >\n                Delete\n            </Button>\n        </div>\n        {/each}\n    </div>\n</Loadable>\n\n{#if creatingToken}\n<CreateApiTokenModal\n    bind:isOpen={creatingToken}\n    create={createToken}\n/>\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/gateway/App.svelte",
    "content": "<script lang=\"ts\">\n    import Router, { push, type RouteDetail } from 'svelte-spa-router'\n    import { wrap } from 'svelte-spa-router/wrap'\n    import { get } from 'svelte/store'\n    import { reloadServerInfo, serverInfo } from 'gateway/lib/store'\n    import ThemeSwitcher from 'common/ThemeSwitcher.svelte'\n    import DelayedSpinner from 'common/DelayedSpinner.svelte'\n    import AuthBar from 'common/AuthBar.svelte'\n    import Brand from 'common/Brand.svelte'\n    import Loadable from 'common/Loadable.svelte'\n    import { api, type AuthStateResponseInternal } from './lib/api'\n    import { Button } from '@sveltestrap/sveltestrap'\n    import Fa from 'svelte-fa'\n    import { faArrowRight, faCog } from '@fortawesome/free-solid-svg-icons'\n    import { hasAdminAccess } from 'admin/lib/store'\n\n    let redirecting = $state(false)\n    let serverInfoPromise = reloadServerInfo()\n    let webAuthRequests: AuthStateResponseInternal[] = $state([])\n    let doNotShowAuthRequests = $state(false)\n\n    async function init () {\n        await serverInfoPromise\n    }\n\n    function onPageResume () {\n        redirecting = false\n    }\n\n    async function reloadWebAuthRequests () {\n        webAuthRequests = await api.getWebAuthRequests()\n    }\n\n    async function requireLogin (detail: RouteDetail) {\n        await serverInfoPromise\n        if (!get(serverInfo)?.username) {\n            let url = location.pathname + '#' + detail.location\n            if (detail.querystring) {\n                url += '?' + detail.querystring\n            }\n            push('/login?next=' + encodeURIComponent(url))\n            return false\n        }\n        return true\n    }\n\n    const routes = {\n        '/': wrap({\n            asyncComponent: () => import('./TargetList.svelte') as any,\n            props: {\n                'on:navigation': () => redirecting = true,\n            },\n            conditions: [requireLogin],\n        }),\n        '/profile': wrap({\n            asyncComponent: () => import('./Profile.svelte') as any,\n            conditions: [requireLogin],\n        }),\n        '/profile/api-tokens': wrap({\n            asyncComponent: () => import('./ProfileApiTokens.svelte') as any,\n            conditions: [requireLogin],\n        }),\n        '/profile/credentials': wrap({\n            asyncComponent: () => import('./ProfileCredentials.svelte') as any,\n            conditions: [requireLogin],\n        }),\n        '/login': wrap({\n            asyncComponent: () => import('./Login.svelte') as any,\n        }),\n        '/login/:stateId': wrap({\n            asyncComponent: () => import('./OutOfBandAuth.svelte') as any,\n            conditions: [requireLogin],\n            userData: {\n                doNotShowAuthRequests: true,\n            },\n        }),\n    }\n\n    const initPromise = init()\n    let socket: WebSocket | null = null\n\n    $effect(() => {\n        // eslint-disable-next-line @typescript-eslint/no-unused-expressions\n        $serverInfo?.username // trigger effect on username change\n        try {\n            socket?.close()\n        } catch {\n            // ignore\n        }\n        socket = null\n        if ($serverInfo?.username) {\n            socket = new WebSocket(`wss://${location.host}/@warpgate/api/auth/web-auth-requests/stream`)\n            socket.addEventListener('message', () => {\n                reloadWebAuthRequests()\n            })\n            reloadWebAuthRequests()\n        }\n    })\n</script>\n\n<svelte:window on:pageshow={onPageResume}/>\n\n<div class=\"container\">\n    <Loadable promise={initPromise}>\n        {#if redirecting}\n            <DelayedSpinner />\n        {:else}\n            <div class=\"d-flex align-items-center mt-5 mb-5\">\n                <a class=\"logo\" href=\"/@warpgate\">\n                    <Brand />\n                </a>\n\n                <div class=\"ms-auto d-flex align-items-center\">\n                    {#if $hasAdminAccess}\n                    <a href=\"/@warpgate/admin\" class=\"btn btn-warning btn-sm d-flex align-items-center gap-1 me-3\">\n                        <Fa icon={faCog} class=\"mx-1\" />\n                        <span class=\"me-1\">Admin</span>\n                    </a>\n                    {/if}\n\n                    <AuthBar />\n                </div>\n            </div>\n\n            {#if !doNotShowAuthRequests}\n            {#each webAuthRequests as authRequest (authRequest.id)}\n                <Button\n                    color=\"success\"\n                    class=\"mb-4 d-flex align-items-center w-100 text-start\"\n                    on:click={() => {\n                        push('/login/' + authRequest.id)\n                    }}\n                >\n                    <div>\n                        <strong class=\"d-block\">\n                            {authRequest.protocol} authentication request\n                        </strong>\n                        {#if authRequest.address}\n                            <small>\n                                From {authRequest.address}\n                            </small>\n                        {/if}\n                    </div>\n                    <Fa class=\"ms-auto\" icon={faArrowRight} />\n                </Button>\n            {/each}\n            {/if}\n\n            <main>\n                <Router {routes} on:routeLoaded={e => {\n                    doNotShowAuthRequests = !!(e.detail.userData as any)?.['doNotShowAuthRequests']\n                }} />\n            </main>\n\n            <footer class=\"mt-5\">\n                {#if $serverInfo?.version}\n                <span class=\"ms-3 me-auto\">\n                    {$serverInfo.version}\n                </span>\n                {:else}\n                <div class=\"me-auto\"></div>\n                {/if}\n                <ThemeSwitcher />\n            </footer>\n        {/if}\n    </Loadable>\n</div>\n\n<style lang=\"scss\">\n    .container {\n        width: 500px;\n        max-width: 100vw;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/gateway/CreateApiTokenModal.svelte",
    "content": "<script lang=\"ts\">\n    import {\n        Button,\n        Form,\n        FormGroup,\n        Input,\n        Modal,\n        ModalBody,\n        ModalFooter,\n    } from '@sveltestrap/sveltestrap'\n\n    import ModalHeader from 'common/sveltestrap-s5-ports/ModalHeader.svelte'\n\n    interface Props {\n        isOpen: boolean\n        create: (label: string, expiry: Date) => void\n    }\n\n    let {\n        isOpen = $bindable(true),\n        create,\n    }: Props = $props()\n    let label = $state('')\n    let expiry = $state(new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString())\n    let field: HTMLInputElement|undefined = $state()\n    let validated = $state(false)\n\n    function _save () {\n        create(label, new Date(expiry))\n        _cancel()\n    }\n\n    function _cancel () {\n        isOpen = false\n        label = ''\n    }\n</script>\n\n<Modal toggle={_cancel} isOpen={isOpen} on:open={() => field?.focus()}>\n    <Form {validated} on:submit={e => {\n        _save()\n        e.preventDefault()\n    }}>\n        <ModalHeader>\n            New API token\n        </ModalHeader>\n        <ModalBody>\n            <FormGroup floating label=\"Descriptive label\">\n                <Input\n                    bind:inner={field}\n                    required\n                    bind:value={label} />\n            </FormGroup>\n\n            <FormGroup floating label=\"Expiry\" spacing=\"0\">\n                <Input\n                    type=\"datetime-local\"\n                    bind:value={expiry}  />\n            </FormGroup>\n        </ModalBody>\n        <ModalFooter>\n            <Button\n                color=\"primary\"\n                class=\"modal-button\"\n                on:click={() => validated = true}\n            >Create</Button>\n\n            <Button\n                color=\"danger\"\n                class=\"modal-button\"\n                on:click={_cancel}\n            >Cancel</Button>\n        </ModalFooter>\n    </Form>\n</Modal>\n"
  },
  {
    "path": "warpgate-web/src/gateway/CredentialManager.svelte",
    "content": "<script lang=\"ts\">\n    import { api, CredentialKind, PasswordState, type CredentialsState, type ExistingOtpCredential, type ExistingPublicKeyCredential, type ExistingCertificateCredential } from 'gateway/lib/api'\n    import { serverInfo } from 'gateway/lib/store'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import { faCertificate, faIdBadge, faKey, faKeyboard, faMobilePhone } from '@fortawesome/free-solid-svg-icons'\n    import Fa from 'svelte-fa'\n    import PublicKeyCredentialModal from 'admin/PublicKeyCredentialModal.svelte'\n    import CertificateCredentialModal from 'admin/CertificateCredentialModal.svelte'\n    import CreatePasswordModal from 'admin/CreatePasswordModal.svelte'\n    import CreateOtpModal from 'admin/CreateOtpModal.svelte'\n    import CredentialUsedStateBadge from 'common/CredentialUsedStateBadge.svelte'\n    import Loadable from 'common/Loadable.svelte'\n    import { Button } from '@sveltestrap/sveltestrap'\n    import Tooltip from 'common/sveltestrap-s5-ports/Tooltip.svelte'\n\n    let creds: CredentialsState | undefined = $state()\n\n    let creatingPublicKeyCredential = $state(false)\n    let issuingCertificateCredential = $state(false)\n    let creatingOtpCredential = $state(false)\n    let changingPassword = $state(false)\n\n    const initPromise = init()\n\n    async function init () {\n        creds = await api.getMyCredentials()\n    }\n\n    async function changePassword (password: string) {\n        const state = await api.changeMyPassword({ changePasswordRequest: { password } })\n        creds!.password = state\n    }\n\n    async function createPublicKey (label: string, opensshPublicKey: string) {\n        const credential = await api.addMyPublicKey({\n            newPublicKeyCredential: {\n                label,\n                opensshPublicKey,\n            },\n        })\n        creds!.publicKeys.push(credential)\n    }\n\n    async function deletePublicKey (credential: ExistingPublicKeyCredential) {\n        creds!.publicKeys = creds!.publicKeys.filter(c => c.id !== credential.id)\n        await api.deleteMyPublicKey(credential)\n    }\n\n    async function createOtp (secretKey: number[]) {\n        const credential = await api.addMyOtp({\n            newOtpCredential: {\n                secretKey,\n            },\n        })\n        creds!.otp.push(credential)\n    }\n\n    async function deleteOtp (credential: ExistingOtpCredential) {\n        creds!.otp = creds!.otp.filter(c => c.id !== credential.id)\n        await api.deleteMyOtp(credential)\n    }\n\n    async function issueCertificate (label: string, publicKeyPem: string) {\n        const response = await api.issueMyCertificate({\n            issueCertificateCredentialRequest: {\n                label,\n                publicKeyPem,\n            },\n        })\n        creds!.certificates.push(response.credential)\n        return response\n    }\n\n    async function deleteCertificate (credential: ExistingCertificateCredential) {\n        if (confirm('Permanently revoke certificate?')) {\n            creds!.certificates = creds!.certificates.filter(c => c.id !== credential.id)\n            await api.revokeMyCertificate(credential)\n        }\n    }\n</script>\n\n<Loadable promise={initPromise}>\n{#if creds}\n    <div class=\"d-flex align-items-center mt-4 mb-2\">\n        <h4 class=\"m-0\">Password</h4>\n    </div>\n\n    <div class=\"list-group list-group-flush mb-3\">\n        <div class=\"list-group-item credential\">\n            {#if creds.password === PasswordState.Unset}\n                <span class=\"label ms-3\">Your account has no password set</span>\n            {/if}\n            {#if creds.password === PasswordState.Set}\n                <Fa fw icon={faKeyboard} />\n                <span class=\"label ms-3\">Password set</span>\n            {/if}\n            {#if creds.password === PasswordState.MultipleSet}\n                <Fa fw icon={faKeyboard} />\n                <span class=\"label ms-3\">Multiple passwords set</span>\n            {/if}\n\n            <span class=\"ms-auto\"></span>\n            <Button\n                class=\"ms-2\"\n                color=\"link\"\n                onclick={e => {\n                    changingPassword = true\n                    e.preventDefault()\n                }}>\n                {#if creds.password === PasswordState.Unset}\n                    Set password\n                {/if}\n                {#if creds.password === PasswordState.Set}\n                    Change\n                {/if}\n                {#if creds.password === PasswordState.MultipleSet}\n                    Reset password\n                {/if}\n            </Button>\n        </div>\n    </div>\n\n    {#if creds.publicKeys.length === 0 && Object.values(creds.credentialPolicy).some(l => l?.includes(CredentialKind.Password))}\n        <Alert color=\"warning\">\n            Your credential policy requires using a password for authentication. Without one, you won't be able to log in.\n        </Alert>\n    {/if}\n\n    <div class=\"d-flex align-items-center mt-4 mb-2\">\n        <h4 class=\"m-0\">One-time passwords</h4>\n        <span class=\"ms-auto\"></span>\n        <Button color=\"link\" onclick={e => {\n            creatingOtpCredential = true\n            e.preventDefault()\n        }}>Add device</Button>\n    </div>\n\n    <div class=\"list-group list-group-flush mb-3\">\n        {#each creds.otp as credential (credential.id)}\n        <div class=\"list-group-item credential\">\n            <Fa fw icon={faMobilePhone} />\n            <span class=\"label ms-3\">OTP device</span>\n            <span class=\"ms-auto\"></span>\n            <Button\n                class=\"ms-2\"\n                color=\"link\"\n                onclick={e => {\n                    deleteOtp(credential)\n                    e.preventDefault()\n                }}\n            >\n                Delete\n            </Button>\n        </div>\n        {/each}\n    </div>\n\n    {#if creds.otp.length === 0 && Object.values(creds.credentialPolicy).some(l => l?.includes(CredentialKind.Totp))}\n        <Alert color=\"warning\">\n            Your credential policy requires using a one-time password for authentication. Without one, you won't be able to log in.\n        </Alert>\n    {/if}\n\n    <div class=\"d-flex align-items-center mt-4 mb-2\">\n        <h4 class=\"m-0\">Public keys</h4>\n        <span class=\"ms-auto\"></span>\n        <Button\n            color=\"link\"\n            id=\"addPublicKeyCredentialButton\"\n            title={creds.ldapLinked ? 'SSH keys are managed by LDAP' : ''}\n            onclick={e => {\n                if (creds?.ldapLinked) {\n                    return\n                }\n                creatingPublicKeyCredential = true\n                e.preventDefault()\n            }}\n        >Add key</Button>\n        <Tooltip delay=\"250\" target=\"addPublicKeyCredentialButton\" animation>Public key credentials will be loaded from LDAP</Tooltip>\n    </div>\n\n    <div class=\"list-group list-group-flush mb-3\">\n        {#each creds.publicKeys as credential (credential.id)}\n        <div class=\"list-group-item credential\">\n            <Fa fw icon={faKey} />\n            <div class=\"main ms-3\">\n                <div class=\"label\">{credential.label}</div>\n                <small class=\"d-block text-muted\">{credential.abbreviated}</small>\n            </div>\n            <span class=\"ms-auto\"></span>\n            <CredentialUsedStateBadge credential={credential} />\n            <Button\n                class=\"ms-2\"\n                color=\"link\"\n                disabled={creds.ldapLinked}\n                title={creds.ldapLinked ? 'SSH keys are managed by LDAP' : ''}\n                onclick={e => {\n                    deletePublicKey(credential)\n                    e.preventDefault()\n                }}\n            >\n                Delete\n            </Button>\n        </div>\n        {/each}\n    </div>\n\n    {#if creds.publicKeys.length === 0 && creds.credentialPolicy.ssh?.includes(CredentialKind.PublicKey)}\n        <Alert color=\"warning\">\n            Your credential policy requires using a public key for authentication. Without one, you won't be able to log in.\n        </Alert>\n    {/if}\n\n    <div class=\"d-flex align-items-center mt-4 mb-2\">\n        <h4 class=\"m-0\">Certificates</h4>\n        <span class=\"ms-auto\"></span>\n        <Button color=\"link\" onclick={e => {\n            issuingCertificateCredential = true\n            e.preventDefault()\n        }}>Issue certificate</Button>\n    </div>\n\n    <div class=\"list-group list-group-flush mb-3\">\n        {#each creds.certificates as credential (credential.id)}\n        <div class=\"list-group-item credential\">\n            <Fa fw icon={faCertificate} />\n            <div class=\"main ms-3 abbreviate\">\n                <div class=\"label\">{credential.label}</div>\n                <small class=\"d-block text-muted abbreviate\">SHA-256: <code>{credential.fingerprint}</code></small>\n            </div>\n            <span class=\"ms-auto\"></span>\n            <CredentialUsedStateBadge credential={credential} />\n            <Button\n            color=\"link\"\n                class=\"ms-2\"\n                onclick={e => {\n                    deleteCertificate(credential)\n                    e.preventDefault()\n                }}\n            >\n                Delete\n            </Button>\n        </div>\n        {/each}\n    </div>\n\n    {#if creds.certificates.length === 0 && creds.credentialPolicy.kubernetes?.includes(CredentialKind.Certificate)}\n        <Alert color=\"warning\">\n            Your credential policy requires using a certificate for authentication. Without one, you won't be able to log in.\n        </Alert>\n    {/if}\n\n    {#if creds.sso.length > 0}\n    <div class=\"d-flex align-items-center mt-4 mb-2\">\n        <h4 class=\"m-0\">Single sign-on</h4>\n    </div>\n\n    <div class=\"list-group list-group-flush mb-3\">\n        {#each creds.sso as credential (credential.id)}\n        <div class=\"list-group-item credential\">\n            <Fa fw icon={faIdBadge} />\n            <span class=\"label ms-3\">\n                {credential.email}\n                {#if credential.provider} ({credential.provider}){/if}\n            </span>\n        </div>\n        {/each}\n    </div>\n    {/if}\n{/if}\n</Loadable>\n\n{#if changingPassword}\n<CreatePasswordModal\n    bind:isOpen={changingPassword}\n    create={changePassword}\n/>\n{/if}\n\n{#if creatingPublicKeyCredential}\n<PublicKeyCredentialModal\n    bind:isOpen={creatingPublicKeyCredential}\n    save={createPublicKey}\n/>\n{/if}\n\n{#if creatingOtpCredential}\n<CreateOtpModal\n    bind:isOpen={creatingOtpCredential}\n    username={$serverInfo!.username!}\n    create={createOtp}\n/>\n{/if}\n\n{#if issuingCertificateCredential}\n<CertificateCredentialModal\n    bind:isOpen={issuingCertificateCredential}\n    save={issueCertificate}\n    username={$serverInfo!.username!}\n    onClose={() => {\n        issuingCertificateCredential = false\n    }}\n/>\n{/if}\n\n<style lang=\"scss\">\n    .credential {\n        display: flex;\n        align-items: center;\n        padding-left: 0;\n        padding-right: 0;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/gateway/Login.svelte",
    "content": "<script lang=\"ts\">\n    import { get } from 'svelte/store'\n    import { querystring, replace } from 'svelte-spa-router'\n    import { Button, FormGroup } from '@sveltestrap/sveltestrap'\n    import Fa from 'svelte-fa'\n    import { faArrowRight } from '@fortawesome/free-solid-svg-icons'\n    import { faGoogle, faMicrosoft, faApple } from '@fortawesome/free-brands-svg-icons'\n\n    import { api, ApiAuthState, LoginFailureResponseFromJSON, type SsoProviderDescription, SsoProviderKind, ResponseError } from 'gateway/lib/api'\n    import { reloadServerInfo, serverInfo } from 'gateway/lib/store'\n    import { stringifyError } from 'common/errors'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Loadable from 'common/Loadable.svelte'\n\n    let error: string|null = $state(null)\n    let username = $state('')\n    let password = $state('')\n    let otp = $state('')\n    let busy = $state(false)\n    let otpInput: HTMLInputElement|undefined = $state()\n    let authState: ApiAuthState|undefined = $state()\n    let ssoProvidersPromise = api.getSsoProviders()\n    let showPasswordLogin = $state(false)\n\n    const nextURL = new URLSearchParams(get(querystring)).get('next') ?? undefined\n    const serverErrorMessage = new URLSearchParams(location.search).get('login_error')\n    const initPromise = init()\n\n    async function init () {\n        try {\n            authState = (await api.getDefaultAuthState()).state\n        } catch (err) {\n            if (err instanceof ResponseError) {\n                if (err.response.status === 404) {\n                    authState = ApiAuthState.NotStarted\n                }\n            } else {\n                throw err\n            }\n        }\n        continueWithState()\n    }\n\n    function success () {\n        if (nextURL) {\n            location.assign(nextURL)\n        } else {\n            replace('/')\n        }\n    }\n\n    async function continueWithState () {\n        if (authState === ApiAuthState.Success) {\n            success()\n        }\n        if (authState === ApiAuthState.SsoNeeded) {\n            const providers = await ssoProvidersPromise\n            if (!providers.length) {\n                // todo\n            }\n            if (providers.length === 1) {\n                startSSO(providers[0]!)\n            }\n        }\n        if (authState === ApiAuthState.OtpNeeded) {\n            setTimeout(() => {\n                otpInput?.focus()\n            })\n        }\n    }\n\n    async function login () {\n        busy = true\n        try {\n            await _login()\n        } finally {\n            busy = false\n        }\n    }\n\n    async function _login () {\n        error = null\n        try {\n            if (authState === ApiAuthState.OtpNeeded) {\n                await api.otpLogin({\n                    otpLoginRequest: {\n                        otp,\n                    },\n                })\n            } else {\n                await api.login({\n                    loginRequest: {\n                        username,\n                        password,\n                    },\n                })\n            }\n            await reloadServerInfo()\n            success()\n        } catch (err) {\n            if (err instanceof ResponseError) {\n                if (err.response.status === 401) {\n                    const failure = LoginFailureResponseFromJSON(await err.response.json())\n                    authState = failure.state\n\n                    continueWithState()\n                } else {\n                    error = await err.response.text()\n                }\n            } else {\n                error = await stringifyError(err)\n            }\n        }\n    }\n\n    async function cancel () {\n        await api.cancelDefaultAuth()\n        location.reload()\n    }\n\n    async function startSSO (provider: SsoProviderDescription) {\n        busy = true\n        try {\n            const p = await api.startSso({ name: provider.name, next: nextURL })\n            location.href = p.url\n        } catch (err) {\n            error = await stringifyError(err)\n            busy = false\n        }\n    }\n</script>\n\n{#snippet localLoginForm()}\n    <form autocomplete=\"on\" onsubmit={e => {\n        login()\n        e.preventDefault()\n    }}>\n        <FormGroup floating label=\"Username\">\n            <!-- svelte-ignore a11y_autofocus -->\n            <input\n                bind:value={username}\n                name=\"username\"\n                autocomplete=\"username\"\n                disabled={busy}\n                class=\"form-control\"\n                required\n                autofocus />\n        </FormGroup>\n\n        <FormGroup floating label=\"Password\">\n            <input\n                bind:value={password}\n                name=\"password\"\n                type=\"password\"\n                autocomplete=\"current-password\"\n                disabled={busy}\n                required\n                class=\"form-control\" />\n        </FormGroup>\n\n        <Button\n            class=\"d-flex align-items-center\"\n            color=\"primary\"\n            type=\"submit\"\n            disabled={busy}\n        >\n            Login\n            <Fa class=\"ms-2\" fw icon={faArrowRight} />\n        </Button>\n    </form>\n{/snippet}\n\n<Loadable promise={initPromise}>\n\n    <div class=\"mt-5\">\n        <div class=\"page-summary-bar\">\n            {#if authState === ApiAuthState.NotStarted || authState === ApiAuthState.Failed}\n                <h1>Welcome</h1>\n            {:else}\n                <h1>Continue login</h1>\n            {/if}\n        </div>\n        {#if authState === ApiAuthState.OtpNeeded}\n            <form class=\"d-flex align-items-stretch gap-2\" onsubmit={e => {\n                login()\n                e.preventDefault()\n            }}>\n                <FormGroup floating label=\"One-time password\" class=\"w-100\">\n                    <!-- svelte-ignore a11y_autofocus -->\n                    <input\n                        bind:value={otp}\n                        bind:this={otpInput}\n                        name=\"otp\"\n                        required\n                        pattern=\"\\d&lbrace;6,8&rbrace;\"\n                        autofocus\n                        inputmode=\"numeric\"\n                        disabled={busy}\n                        class=\"form-control\" />\n                </FormGroup>\n\n                <Button\n                class=\"mb-3\"\n                    color=\"primary\"\n                    type=\"submit\"\n                    disabled={busy}\n                >\n                    <Fa icon={faArrowRight} />\n                </Button>\n            </form>\n        {/if}\n        {#if (authState === ApiAuthState.NotStarted || authState === ApiAuthState.PasswordNeeded || authState === ApiAuthState.Failed) && (!$serverInfo?.minimizePasswordLogin || showPasswordLogin)}\n            <!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->\n            {@render localLoginForm()}\n        {/if}\n\n        <div class=\"mt-3\"></div>\n\n        {#if authState === ApiAuthState.Failed}\n            <Alert color=\"danger\">Incorrect credentials</Alert>\n        {/if}\n        {#if serverErrorMessage}\n            <Alert color=\"danger\">{serverErrorMessage}</Alert>\n        {/if}\n        {#if error}\n            <Alert color=\"danger\">{error}</Alert>\n        {/if}\n    </div>\n\n    {#if authState === ApiAuthState.SsoNeeded || authState === ApiAuthState.NotStarted || authState === ApiAuthState.Failed}\n        <Loadable promise={ssoProvidersPromise}>\n            {#snippet children(ssoProviders)}\n                <div class=\"mt-3 sso-buttons\">\n                    {#each ssoProviders as ssoProvider (ssoProvider.name)}\n                        <button\n                            class=\"btn btn-secondary\"\n                            disabled={busy}\n                            onclick={() => startSSO(ssoProvider)}\n                        >\n                            {#if ssoProvider.kind === SsoProviderKind.Google}\n                                <Fa fw class=\"me-2\" icon={faGoogle} />\n                            {/if}\n                            {#if ssoProvider.kind === SsoProviderKind.Azure}\n                                <Fa fw class=\"me-2\" icon={faMicrosoft} />\n                            {/if}\n                            {#if ssoProvider.kind === SsoProviderKind.Apple}\n                                <Fa fw class=\"me-2\" icon={faApple} />\n                            {/if}\n                            {ssoProvider.label || ssoProvider.name}\n                        </button>\n                    {/each}\n                </div>\n            {/snippet}\n        </Loadable>\n    {/if}\n\n    {#if (authState === ApiAuthState.NotStarted || authState === ApiAuthState.PasswordNeeded || authState === ApiAuthState.Failed) && $serverInfo?.minimizePasswordLogin && !showPasswordLogin}\n        <div class=\"mt-3 text-center\">\n            <!-- svelte-ignore a11y_invalid_attribute -->\n            <a\n                href=\"#\"\n                class=\"password-login-link\"\n                onclick={e => {\n                    e.preventDefault()\n                    showPasswordLogin = true\n                }}\n            >\n                Password login\n            </a>\n        </div>\n    {/if}\n\n    {#if authState !== ApiAuthState.NotStarted && authState !== ApiAuthState.Failed}\n        <button\n            class=\"btn w-100 mt-3 btn-secondary\"\n            onclick={cancel}\n        >\n            Cancel\n        </button>\n    {/if}\n</Loadable>\n\n<style lang=\"scss\">\n    h1 {\n        font-size: 3rem;\n    }\n\n    .sso-buttons {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 0.85rem 1rem;\n\n        button {\n            flex: 1 0 0;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            text-wrap: nowrap;\n        }\n    }\n\n\n</style>\n"
  },
  {
    "path": "warpgate-web/src/gateway/OutOfBandAuth.svelte",
    "content": "<script lang=\"ts\">\n    import { api, ApiAuthState, type AuthStateResponseInternal } from 'gateway/lib/api'\n    import AsyncButton from 'common/AsyncButton.svelte'\n    import RelativeDate from 'admin/RelativeDate.svelte'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import Loadable from 'common/Loadable.svelte'\n\n    interface Props {\n        params: { stateId: string };\n    }\n\n    let { params }: Props = $props()\n\n    let authState: AuthStateResponseInternal | undefined = $state()\n\n    async function reload () {\n        authState = await api.getAuthState({ id: params.stateId })\n    }\n\n    async function init () {\n        await reload()\n    }\n\n    async function approve () {\n        api.approveAuth({ id: params.stateId })\n        await reload()\n        window.close()\n    }\n\n    async function reject () {\n        api.rejectAuth({ id: params.stateId })\n        await reload()\n        window.close()\n    }\n</script>\n\n<style lang=\"scss\">\n    .identification-string {\n        display: flex;\n        font-size: 3rem;\n\n        .card {\n            padding: 0rem 0.5rem;\n            border-radius: .5rem;\n            margin-right: .5rem;\n        }\n    }\n</style>\n\n<Loadable promise={init()}>\n{#if authState}\n    <div class=\"page-summary-bar\">\n        <h1>authorization request</h1>\n    </div>\n\n    <div class=\"mb-5\">\n        <div class=\"mb-2\">Ensure this security key matches your authentication prompt:</div>\n        <div class=\"identification-string\">\n            <!-- eslint-disable-next-line svelte/require-each-key -->\n            {#each authState?.identificationString as char}\n                <div class=\"card bg-secondary text-light\">\n                    <div class=\"card-body\">{char}</div>\n                </div>\n            {/each}\n        </div>\n    </div>\n\n    <div class=\"mb-3\">\n        <div>\n            Authorize this {authState.protocol} session?\n        </div>\n        <small>\n            Requested <RelativeDate date={authState.started} />\n            {#if authState.address}from {authState.address}{/if}\n        </small>\n    </div>\n\n    {#if authState.state === ApiAuthState.Success}\n        <Alert color=\"success\">\n            Approved\n        </Alert>\n    {:else if authState.state === ApiAuthState.Failed}\n        <Alert color=\"danger\">\n            Rejected\n        </Alert>\n    {:else}\n        <div class=\"d-flex\">\n            <AsyncButton\n                color=\"primary\"\n                class=\"d-flex align-items-center ms-auto\"\n                click={approve}\n            >\n                Authorize\n            </AsyncButton>\n            <AsyncButton\n                color=\"secondary\"\n                class=\"d-flex align-items-center ms-2\"\n                click={reject}\n            >\n                Reject\n            </AsyncButton>\n        </div>\n    {/if}\n{/if}\n</Loadable>\n"
  },
  {
    "path": "warpgate-web/src/gateway/Profile.svelte",
    "content": "<script lang=\"ts\">\n    import { serverInfo } from 'gateway/lib/store'\n    import NavListItem from 'common/NavListItem.svelte'\n</script>\n\n<div class=\"page-summary-bar\">\n    <h1>{$serverInfo!.username}</h1>\n</div>\n\n<NavListItem\n    title=\"API tokens\"\n    description=\"Manage your API tokens\"\n    href=\"/profile/api-tokens\"\n/>\n\n{#if $serverInfo}\n    {#if $serverInfo.ownCredentialManagementAllowed}\n        <NavListItem\n            title=\"Credentials\"\n            description=\"Manage your passwords and keys\"\n            href=\"/profile/credentials\"\n        />\n    {/if}\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/gateway/ProfileApiTokens.svelte",
    "content": "<script lang=\"ts\">\n    import { faFileContract, faFlaskVial } from '@fortawesome/free-solid-svg-icons'\n    import Fa from 'svelte-fa'\n    import ApiTokenManager from './ApiTokenManager.svelte'\n    import InfoBox from 'common/InfoBox.svelte'\n</script>\n\n<ApiTokenManager />\n\n<div class=\"row mt-5\">\n    <div class=\"col\">\n        <h4>User API</h4>\n        <a class=\"link\" target=\"_blank\" href=\"/@warpgate/api/playground\">\n            <Fa icon={faFlaskVial} fw />\n            <span>Playground</span>\n        </a>\n        <a class=\"link\" target=\"_blank\" href=\"/@warpgate/api/openapi.json\">\n            <Fa icon={faFileContract} fw />\n            <span>Schema</span>\n        </a>\n    </div>\n\n    <div class=\"col\">\n        <h4>Admin API</h4>\n        <a class=\"link\" target=\"_blank\" href=\"/@warpgate/admin/api/playground\">\n            <Fa icon={faFlaskVial} fw />\n            <span>Playground</span>\n        </a>\n        <a class=\"link\" target=\"_blank\" href=\"/@warpgate/admin/api/openapi.json\">\n            <Fa icon={faFileContract} fw />\n            <span>Schema</span>\n        </a>\n    </div>\n</div>\n\n<InfoBox class=\"mt-5\">\n    Pass the token in the <code>X-Warpgate-Token</code> header\n</InfoBox>\n\n<style lang=\"scss\">\n    .link {\n        display: flex;\n        align-items: center;\n\n        span {\n            margin-left: 0.25rem;\n        }\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/gateway/ProfileCredentials.svelte",
    "content": "<script lang=\"ts\">\n    import { serverInfo } from 'gateway/lib/store'\n    import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'\n    import CredentialManager from './CredentialManager.svelte'\n</script>\n\n<div class=\"page-summary-bar\">\n    <h1>Credentials</h1>\n</div>\n\n{#if $serverInfo}\n    {#if $serverInfo.ownCredentialManagementAllowed}\n        <CredentialManager />\n    {:else}\n        <Alert color=\"info\">\n            Credential management is disabled by your administrator\n        </Alert>\n    {/if}\n{/if}\n"
  },
  {
    "path": "warpgate-web/src/gateway/TargetList.svelte",
    "content": "<script lang=\"ts\">\nimport { Observable, from, map } from 'rxjs'\nimport { compare as naturalCompareFactory } from 'natural-orderby'\nimport { faArrowRight } from '@fortawesome/free-solid-svg-icons'\nimport ConnectionInstructions from 'common/ConnectionInstructions.svelte'\nimport ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'\nimport { api, type TargetSnapshot, TargetKind, BootstrapThemeColor } from 'gateway/lib/api'\nimport Fa from 'svelte-fa'\nimport { Button, Modal, ModalBody, ModalFooter } from '@sveltestrap/sveltestrap'\nimport { serverInfo } from './lib/store'\nimport { firstBy } from 'thenby'\nimport GettingStarted from 'common/GettingStarted.svelte'\nimport EmptyState from 'common/EmptyState.svelte'\nimport GroupColorCircle from 'common/GroupColorCircle.svelte'\n\nlet selectedTarget: TargetSnapshot|undefined = $state()\n\nfunction loadTargets(\n    options: LoadOptions\n): Observable<PaginatedResponse<TargetSnapshot>> {\n    return from(api.getTargets({ search: options.search })).pipe(\n        map(result => {\n            const naturalCompare = naturalCompareFactory()\n\n            result = result.sort(\n                firstBy<TargetSnapshot, boolean>((x: TargetSnapshot) => !x.group)\n                    // Natural sort between groups\n                    .thenBy((a: TargetSnapshot, b: TargetSnapshot) =>\n                        naturalCompare(\n                            (a.group?.name ?? '').toLowerCase(),\n                            (b.group?.name ?? '').toLowerCase()\n                        )\n                    )\n                    // Natural sort within a group\n                    .thenBy((a: TargetSnapshot, b: TargetSnapshot) =>\n                        naturalCompare(\n                            a.name.toLowerCase(),\n                            b.name.toLowerCase()\n                        )\n                    )\n            )\n\n            return {\n                items: result,\n                offset: 0,\n                total: result.length,\n            }\n        }),\n    )\n}\n\nfunction selectTarget (target: TargetSnapshot) {\n    if (target.kind === TargetKind.Http) {\n        if (target.externalHost) {\n            const port = location.port ? `:${location.port}` : ''\n            loadURL(`${location.protocol}//${target.externalHost}${port}`)\n        } else {\n            loadURL(`/?warpgate-target=${target.name}`)\n        }\n    } else {\n        selectedTarget = target\n    }\n}\n\nfunction loadURL (url: string) {\n    location.href = url\n}\n\ninterface GroupInfo {\n    id: string\n    name: string\n    color: BootstrapThemeColor\n}\n\nfunction groupInfoFromTarget (target: TargetSnapshot): GroupInfo {\n    if (!target.group) {\n        return {\n            id: '$ungrouped',\n            name: 'Ungrouped',\n            color: BootstrapThemeColor.Secondary,\n        }\n    }\n    return {\n        id: target.group.id,\n        name: target.group.name,\n        color: target.group.color ?? BootstrapThemeColor.Secondary,\n    }\n}\n\n</script>\n\n{#if $serverInfo?.setupState}\n    <GettingStarted\n        setupState={$serverInfo?.setupState} />\n{/if}\n\n<ItemList load={loadTargets} showSearch={true} groupObject={groupInfoFromTarget} groupKey={group => group.id}>\n    {#snippet empty()}\n        <EmptyState\n            title=\"You don't have access to any targets yet\" />\n    {/snippet}\n    {#snippet groupHeader(group)}\n        <div class=\"d-flex align-items-center gap-2 mb-2 mt-4\">\n            <GroupColorCircle color={group.color} />\n            <div class=\"h5 mb-0\">{group.name}</div>\n        </div>\n    {/snippet}\n    {#snippet item(target)}\n        <a\n            class=\"list-group-item list-group-item-action target-item\"\n            href={\n                target.kind === TargetKind.Http\n                    ? (target.externalHost\n                        ? `${location.protocol}//${target.externalHost}${location.port ? `:${location.port}` : ''}`\n                        : `/?warpgate-target=${target.name}`)\n                    : '/@warpgate/admin'\n            }\n            onclick={e => {\n                if (e.metaKey || e.ctrlKey) {\n                    return\n                }\n                e.preventDefault()\n                selectTarget(target)\n            }}\n        >\n            <span class=\"me-auto\">\n                <div class=\"d-flex align-items-center gap-2\">\n                        {target.name}\n                    </div>\n                    {#if target.description}\n                        <small class=\"d-block text-muted\">{target.description}</small>\n                    {/if}\n            </span>\n            <small class=\"protocol text-muted ms-auto\">\n                {#if target.kind === TargetKind.Ssh}\n                    SSH\n                {/if}\n                {#if target.kind === TargetKind.MySql}\n                    MySQL\n                {/if}\n                {#if target.kind === TargetKind.Postgres}\n                    PostgreSQL\n                {/if}\n                {#if target.kind === TargetKind.Kubernetes}\n                    Kubernetes\n                {/if}\n            </small>\n            {#if target.kind === TargetKind.Http}\n                <Fa icon={faArrowRight} fw />\n            {/if}\n        </a>\n    {/snippet}\n</ItemList>\n\n{#if $serverInfo?.setupState && !$serverInfo.setupState.hasTargets}\n    <EmptyState\n        hint=\"Once you add targets and assign access, they will appear here\"\n        title=\"No other targets yet\" />\n{/if}\n\n<Modal isOpen={!!selectedTarget} toggle={() => selectedTarget = undefined}>\n    <ModalBody>\n        {#if selectedTarget}\n        <ConnectionInstructions\n            targetName={selectedTarget.name}\n            username={$serverInfo?.username}\n            targetKind={selectedTarget.kind ?? TargetKind.Ssh}\n            targetDefaultDatabaseName={\n                (selectedTarget.kind === TargetKind.MySql || selectedTarget.kind === TargetKind.Postgres)\n                    ? selectedTarget.defaultDatabaseName : undefined}\n        />\n        {/if}\n    </ModalBody>\n    <ModalFooter>\n        <Button\n            color=\"secondary\"\n            class=\"modal-button\"\n            block\n            on:click={() => { selectedTarget = undefined }}\n        >\n            Close\n        </Button>\n    </ModalFooter>\n</Modal>\n\n<style lang=\"scss\">\n    .target-item {\n        display: flex;\n        align-items: center;\n    }\n</style>\n"
  },
  {
    "path": "warpgate-web/src/gateway/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <base href=\"/@warpgate\" />\n    <link rel=\"icon\" href=\"/@warpgate/assets/favicon.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Warpgate</title>\n    <style>#app { display: none }</style>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/gateway/index.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "warpgate-web/src/gateway/index.ts",
    "content": "import { mount } from 'svelte'\nimport '../theme'\nimport App from './App.svelte'\n\nmount(App, {\n    target: document.getElementById('app')!,\n})\n\nexport { }\n"
  },
  {
    "path": "warpgate-web/src/gateway/lib/api.ts",
    "content": "import { DefaultApi, Configuration, ResponseError } from './api-client'\n\nconst configuration = new Configuration({\n    basePath: '/@warpgate/api',\n})\n\nexport const api = new DefaultApi(configuration)\nexport * from './api-client'\n\nexport async function stringifyError (err: ResponseError): Promise<string> {\n    return `API error: ${await err.response.text()}`\n}\n"
  },
  {
    "path": "warpgate-web/src/gateway/lib/openapi-schema.json",
    "content": "{\n  \"openapi\": \"3.0.0\",\n  \"info\": {\n    \"title\": \"Warpgate HTTP proxy\",\n    \"version\": \"v0.21.1-5-ga76ef2bb-modified\"\n  },\n  \"servers\": [\n    {\n      \"url\": \"/@warpgate/api\"\n    }\n  ],\n  \"tags\": [],\n  \"paths\": {\n    \"/auth/login\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/LoginRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\"\n          },\n          \"401\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/LoginFailureResponse\"\n                }\n              }\n            }\n          }\n        },\n        \"operationId\": \"login\"\n      }\n    },\n    \"/auth/otp\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/OtpLoginRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\"\n          },\n          \"401\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/LoginFailureResponse\"\n                }\n              }\n            }\n          }\n        },\n        \"operationId\": \"otpLogin\"\n      }\n    },\n    \"/auth/logout\": {\n      \"post\": {\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"logout\"\n      }\n    },\n    \"/auth/state\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/AuthStateResponseInternal\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"get_default_auth_state\"\n      },\n      \"delete\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/AuthStateResponseInternal\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"cancel_default_auth\"\n      }\n    },\n    \"/auth/web-auth-requests\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/AuthStateResponseInternal\"\n                  }\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_web_auth_requests\"\n      }\n    },\n    \"/auth/state/{id}\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/AuthStateResponseInternal\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"get_auth_state\"\n      }\n    },\n    \"/auth/state/{id}/approve\": {\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/AuthStateResponseInternal\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"approve_auth\"\n      }\n    },\n    \"/auth/state/{id}/reject\": {\n      \"post\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/AuthStateResponseInternal\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"reject_auth\"\n      }\n    },\n    \"/info\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/Info\"\n                }\n              }\n            }\n          }\n        },\n        \"operationId\": \"get_info\"\n      }\n    },\n    \"/targets\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"search\",\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/TargetSnapshot\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_targets\"\n      }\n    },\n    \"/sso/providers\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/SsoProviderDescription\"\n                  }\n                }\n              }\n            }\n          }\n        },\n        \"operationId\": \"get_sso_providers\"\n      }\n    },\n    \"/sso/return\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"code\",\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"307\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"return_to_sso\"\n      },\n      \"post\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"text/html; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"string\"\n                }\n              }\n            }\n          }\n        },\n        \"operationId\": \"return_to_sso_with_form_data\"\n      }\n    },\n    \"/sso/logout\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/StartSloResponseParams\"\n                }\n              }\n            }\n          },\n          \"400\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"initiate_sso_logout\"\n      }\n    },\n    \"/sso/providers/{name}/start\": {\n      \"get\": {\n        \"parameters\": [\n          {\n            \"name\": \"name\",\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          },\n          {\n            \"name\": \"next\",\n            \"schema\": {\n              \"type\": \"string\"\n            },\n            \"in\": \"query\",\n            \"required\": false,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/StartSsoResponseParams\"\n                }\n              }\n            }\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"start_sso\"\n      }\n    },\n    \"/profile/credentials\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/CredentialsState\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"get_my_credentials\"\n      }\n    },\n    \"/profile/credentials/password\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/ChangePasswordRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/PasswordState\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"change_my_password\"\n      }\n    },\n    \"/profile/credentials/public-keys\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/NewPublicKeyCredential\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ExistingPublicKeyCredential\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"add_my_public_key\"\n      }\n    },\n    \"/profile/credentials/public-keys/{id}\": {\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"401\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_my_public_key\"\n      }\n    },\n    \"/profile/credentials/otp\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/NewOtpCredential\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/ExistingOtpCredential\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"add_my_otp\"\n      }\n    },\n    \"/profile/credentials/otp/{id}\": {\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"401\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"security\": [\n          {\n            \"TokenSecurityScheme\": []\n          },\n          {\n            \"CookieSecurityScheme\": []\n          }\n        ],\n        \"operationId\": \"delete_my_otp\"\n      }\n    },\n    \"/profile/credentials/certificates\": {\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/IssueCertificateCredentialRequest\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/IssuedCertificateCredential\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"issue_my_certificate\"\n      }\n    },\n    \"/profile/credentials/certificates/{id}\": {\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\"\n          },\n          \"401\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"revoke_my_certificate\"\n      }\n    },\n    \"/profile/api-tokens\": {\n      \"get\": {\n        \"responses\": {\n          \"200\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"type\": \"array\",\n                  \"items\": {\n                    \"$ref\": \"#/components/schemas/ExistingApiToken\"\n                  }\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"get_my_api_tokens\"\n      },\n      \"post\": {\n        \"requestBody\": {\n          \"content\": {\n            \"application/json; charset=utf-8\": {\n              \"schema\": {\n                \"$ref\": \"#/components/schemas/NewApiToken\"\n              }\n            }\n          },\n          \"required\": true\n        },\n        \"responses\": {\n          \"201\": {\n            \"description\": \"\",\n            \"content\": {\n              \"application/json; charset=utf-8\": {\n                \"schema\": {\n                  \"$ref\": \"#/components/schemas/TokenAndSecret\"\n                }\n              }\n            }\n          },\n          \"401\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"create_api_token\"\n      }\n    },\n    \"/profile/api-tokens/{id}\": {\n      \"delete\": {\n        \"parameters\": [\n          {\n            \"name\": \"id\",\n            \"schema\": {\n              \"type\": \"string\",\n              \"format\": \"uuid\"\n            },\n            \"in\": \"path\",\n            \"required\": true,\n            \"deprecated\": false,\n            \"explode\": true\n          }\n        ],\n        \"responses\": {\n          \"204\": {\n            \"description\": \"\"\n          },\n          \"401\": {\n            \"description\": \"\"\n          },\n          \"404\": {\n            \"description\": \"\"\n          }\n        },\n        \"operationId\": \"delete_my_api_token\"\n      }\n    }\n  },\n  \"components\": {\n    \"schemas\": {\n      \"AdminPermissions\": {\n        \"type\": \"object\",\n        \"title\": \"AdminPermissions\",\n        \"required\": [\n          \"targets_create\",\n          \"targets_edit\",\n          \"targets_delete\",\n          \"users_create\",\n          \"users_edit\",\n          \"users_delete\",\n          \"access_roles_create\",\n          \"access_roles_edit\",\n          \"access_roles_delete\",\n          \"access_roles_assign\",\n          \"sessions_view\",\n          \"sessions_terminate\",\n          \"recordings_view\",\n          \"tickets_create\",\n          \"tickets_delete\",\n          \"config_edit\",\n          \"admin_roles_manage\"\n        ],\n        \"properties\": {\n          \"targets_create\": {\n            \"type\": \"boolean\"\n          },\n          \"targets_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"targets_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"users_create\": {\n            \"type\": \"boolean\"\n          },\n          \"users_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"users_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_create\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"access_roles_assign\": {\n            \"type\": \"boolean\"\n          },\n          \"sessions_view\": {\n            \"type\": \"boolean\"\n          },\n          \"sessions_terminate\": {\n            \"type\": \"boolean\"\n          },\n          \"recordings_view\": {\n            \"type\": \"boolean\"\n          },\n          \"tickets_create\": {\n            \"type\": \"boolean\"\n          },\n          \"tickets_delete\": {\n            \"type\": \"boolean\"\n          },\n          \"config_edit\": {\n            \"type\": \"boolean\"\n          },\n          \"admin_roles_manage\": {\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"ApiAuthState\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"NotStarted\",\n          \"Failed\",\n          \"PasswordNeeded\",\n          \"OtpNeeded\",\n          \"SsoNeeded\",\n          \"WebUserApprovalNeeded\",\n          \"PublicKeyNeeded\",\n          \"Success\"\n        ]\n      },\n      \"AuthStateResponseInternal\": {\n        \"type\": \"object\",\n        \"title\": \"AuthStateResponseInternal\",\n        \"required\": [\n          \"id\",\n          \"protocol\",\n          \"started\",\n          \"state\",\n          \"identification_string\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\"\n          },\n          \"protocol\": {\n            \"type\": \"string\"\n          },\n          \"address\": {\n            \"type\": \"string\"\n          },\n          \"started\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"state\": {\n            \"$ref\": \"#/components/schemas/ApiAuthState\"\n          },\n          \"identification_string\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"BootstrapThemeColor\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Primary\",\n          \"Secondary\",\n          \"Success\",\n          \"Danger\",\n          \"Warning\",\n          \"Info\",\n          \"Light\",\n          \"Dark\"\n        ]\n      },\n      \"ChangePasswordRequest\": {\n        \"type\": \"object\",\n        \"title\": \"ChangePasswordRequest\",\n        \"required\": [\n          \"password\"\n        ],\n        \"properties\": {\n          \"password\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"CredentialKind\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Password\",\n          \"PublicKey\",\n          \"Certificate\",\n          \"Totp\",\n          \"Sso\",\n          \"WebUserApproval\"\n        ]\n      },\n      \"CredentialsState\": {\n        \"type\": \"object\",\n        \"title\": \"CredentialsState\",\n        \"required\": [\n          \"password\",\n          \"otp\",\n          \"public_keys\",\n          \"certificates\",\n          \"sso\",\n          \"credential_policy\",\n          \"ldap_linked\"\n        ],\n        \"properties\": {\n          \"password\": {\n            \"$ref\": \"#/components/schemas/PasswordState\"\n          },\n          \"otp\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/ExistingOtpCredential\"\n            }\n          },\n          \"public_keys\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/ExistingPublicKeyCredential\"\n            }\n          },\n          \"certificates\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/ExistingCertificateCredential\"\n            }\n          },\n          \"sso\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/ExistingSsoCredential\"\n            }\n          },\n          \"credential_policy\": {\n            \"$ref\": \"#/components/schemas/UserRequireCredentialsPolicy\"\n          },\n          \"ldap_linked\": {\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"ExistingApiToken\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingApiToken\",\n        \"required\": [\n          \"id\",\n          \"label\",\n          \"created\",\n          \"expiry\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"created\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"expiry\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          }\n        }\n      },\n      \"ExistingCertificateCredential\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingCertificateCredential\",\n        \"required\": [\n          \"id\",\n          \"label\",\n          \"fingerprint\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"date_added\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"last_used\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"fingerprint\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"ExistingOtpCredential\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingOtpCredential\",\n        \"required\": [\n          \"id\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          }\n        }\n      },\n      \"ExistingPublicKeyCredential\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingPublicKeyCredential\",\n        \"required\": [\n          \"id\",\n          \"label\",\n          \"abbreviated\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"date_added\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"last_used\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          },\n          \"abbreviated\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"ExistingSsoCredential\": {\n        \"type\": \"object\",\n        \"title\": \"ExistingSsoCredential\",\n        \"required\": [\n          \"id\",\n          \"email\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"provider\": {\n            \"type\": \"string\"\n          },\n          \"email\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"GroupInfo\": {\n        \"type\": \"object\",\n        \"title\": \"GroupInfo\",\n        \"required\": [\n          \"id\",\n          \"name\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"format\": \"uuid\"\n          },\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"color\": {\n            \"$ref\": \"#/components/schemas/BootstrapThemeColor\"\n          }\n        }\n      },\n      \"Info\": {\n        \"type\": \"object\",\n        \"title\": \"Info\",\n        \"required\": [\n          \"ports\",\n          \"minimize_password_login\",\n          \"authorized_via_ticket\",\n          \"authorized_via_sso_with_single_logout\",\n          \"own_credential_management_allowed\",\n          \"has_ldap\"\n        ],\n        \"properties\": {\n          \"version\": {\n            \"type\": \"string\"\n          },\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"selected_target\": {\n            \"type\": \"string\"\n          },\n          \"external_host\": {\n            \"type\": \"string\"\n          },\n          \"ports\": {\n            \"$ref\": \"#/components/schemas/PortsInfo\"\n          },\n          \"minimize_password_login\": {\n            \"type\": \"boolean\"\n          },\n          \"authorized_via_ticket\": {\n            \"type\": \"boolean\"\n          },\n          \"authorized_via_sso_with_single_logout\": {\n            \"type\": \"boolean\"\n          },\n          \"own_credential_management_allowed\": {\n            \"type\": \"boolean\"\n          },\n          \"has_ldap\": {\n            \"type\": \"boolean\"\n          },\n          \"setup_state\": {\n            \"$ref\": \"#/components/schemas/SetupState\"\n          },\n          \"admin_permissions\": {\n            \"$ref\": \"#/components/schemas/AdminPermissions\"\n          }\n        }\n      },\n      \"IssueCertificateCredentialRequest\": {\n        \"type\": \"object\",\n        \"title\": \"IssueCertificateCredentialRequest\",\n        \"required\": [\n          \"label\",\n          \"public_key_pem\"\n        ],\n        \"properties\": {\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"public_key_pem\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"IssuedCertificateCredential\": {\n        \"type\": \"object\",\n        \"title\": \"IssuedCertificateCredential\",\n        \"required\": [\n          \"credential\",\n          \"certificate_pem\"\n        ],\n        \"properties\": {\n          \"credential\": {\n            \"$ref\": \"#/components/schemas/ExistingCertificateCredential\"\n          },\n          \"certificate_pem\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"LoginFailureResponse\": {\n        \"type\": \"object\",\n        \"title\": \"LoginFailureResponse\",\n        \"required\": [\n          \"state\"\n        ],\n        \"properties\": {\n          \"state\": {\n            \"$ref\": \"#/components/schemas/ApiAuthState\"\n          }\n        }\n      },\n      \"LoginRequest\": {\n        \"type\": \"object\",\n        \"title\": \"LoginRequest\",\n        \"required\": [\n          \"username\",\n          \"password\"\n        ],\n        \"properties\": {\n          \"username\": {\n            \"type\": \"string\"\n          },\n          \"password\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"NewApiToken\": {\n        \"type\": \"object\",\n        \"title\": \"NewApiToken\",\n        \"required\": [\n          \"label\",\n          \"expiry\"\n        ],\n        \"properties\": {\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"expiry\": {\n            \"type\": \"string\",\n            \"format\": \"date-time\"\n          }\n        }\n      },\n      \"NewOtpCredential\": {\n        \"type\": \"object\",\n        \"title\": \"NewOtpCredential\",\n        \"required\": [\n          \"secret_key\"\n        ],\n        \"properties\": {\n          \"secret_key\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"integer\",\n              \"format\": \"uint8\"\n            }\n          }\n        }\n      },\n      \"NewPublicKeyCredential\": {\n        \"type\": \"object\",\n        \"title\": \"NewPublicKeyCredential\",\n        \"required\": [\n          \"label\",\n          \"openssh_public_key\"\n        ],\n        \"properties\": {\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"openssh_public_key\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"OtpLoginRequest\": {\n        \"type\": \"object\",\n        \"title\": \"OtpLoginRequest\",\n        \"required\": [\n          \"otp\"\n        ],\n        \"properties\": {\n          \"otp\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"PasswordState\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Unset\",\n          \"Set\",\n          \"MultipleSet\"\n        ]\n      },\n      \"PortsInfo\": {\n        \"type\": \"object\",\n        \"title\": \"PortsInfo\",\n        \"properties\": {\n          \"ssh\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          },\n          \"http\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          },\n          \"mysql\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          },\n          \"postgres\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          },\n          \"kubernetes\": {\n            \"type\": \"integer\",\n            \"format\": \"uint16\"\n          }\n        }\n      },\n      \"SetupState\": {\n        \"type\": \"object\",\n        \"title\": \"SetupState\",\n        \"required\": [\n          \"has_targets\",\n          \"has_users\"\n        ],\n        \"properties\": {\n          \"has_targets\": {\n            \"type\": \"boolean\"\n          },\n          \"has_users\": {\n            \"type\": \"boolean\"\n          }\n        }\n      },\n      \"SsoProviderDescription\": {\n        \"type\": \"object\",\n        \"title\": \"SsoProviderDescription\",\n        \"required\": [\n          \"name\",\n          \"label\",\n          \"kind\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"label\": {\n            \"type\": \"string\"\n          },\n          \"kind\": {\n            \"$ref\": \"#/components/schemas/SsoProviderKind\"\n          }\n        }\n      },\n      \"SsoProviderKind\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Google\",\n          \"Apple\",\n          \"Azure\",\n          \"Custom\"\n        ]\n      },\n      \"StartSloResponseParams\": {\n        \"type\": \"object\",\n        \"title\": \"StartSloResponseParams\",\n        \"required\": [\n          \"url\"\n        ],\n        \"properties\": {\n          \"url\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"StartSsoResponseParams\": {\n        \"type\": \"object\",\n        \"title\": \"StartSsoResponseParams\",\n        \"required\": [\n          \"url\"\n        ],\n        \"properties\": {\n          \"url\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"TargetKind\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"Http\",\n          \"Kubernetes\",\n          \"MySql\",\n          \"Ssh\",\n          \"Postgres\"\n        ]\n      },\n      \"TargetSnapshot\": {\n        \"type\": \"object\",\n        \"title\": \"TargetSnapshot\",\n        \"required\": [\n          \"name\",\n          \"description\",\n          \"kind\"\n        ],\n        \"properties\": {\n          \"name\": {\n            \"type\": \"string\"\n          },\n          \"description\": {\n            \"type\": \"string\"\n          },\n          \"kind\": {\n            \"$ref\": \"#/components/schemas/TargetKind\"\n          },\n          \"external_host\": {\n            \"type\": \"string\"\n          },\n          \"group\": {\n            \"$ref\": \"#/components/schemas/GroupInfo\"\n          },\n          \"default_database_name\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"TokenAndSecret\": {\n        \"type\": \"object\",\n        \"title\": \"TokenAndSecret\",\n        \"required\": [\n          \"token\",\n          \"secret\"\n        ],\n        \"properties\": {\n          \"token\": {\n            \"$ref\": \"#/components/schemas/ExistingApiToken\"\n          },\n          \"secret\": {\n            \"type\": \"string\"\n          }\n        }\n      },\n      \"UserRequireCredentialsPolicy\": {\n        \"type\": \"object\",\n        \"title\": \"UserRequireCredentialsPolicy\",\n        \"properties\": {\n          \"http\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          },\n          \"kubernetes\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          },\n          \"ssh\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          },\n          \"mysql\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          },\n          \"postgres\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"$ref\": \"#/components/schemas/CredentialKind\"\n            }\n          }\n        }\n      }\n    },\n    \"securitySchemes\": {\n      \"CookieSecurityScheme\": {\n        \"type\": \"apiKey\",\n        \"name\": \"warpgate-http-session\",\n        \"in\": \"cookie\"\n      },\n      \"TokenSecurityScheme\": {\n        \"type\": \"apiKey\",\n        \"name\": \"X-Warpgate-Token\",\n        \"in\": \"header\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "warpgate-web/src/gateway/lib/shellEscape.ts",
    "content": "import { UAParser } from 'ua-parser-js'\n\nfunction escapeUnix (arg: string): string {\n    if (!/^[A-Za-z0-9_/-]+$/.test(arg)) {\n        return ('\\'' + arg.replace(/'/g, '\\'\"\\'\"\\'') + '\\'').replace(/''/g, '')\n    }\n    return arg\n}\n\nfunction escapeWin (arg: string): string {\n    if (!/^[A-Za-z0-9_/-]+$/.test(arg)) {\n        return '\"' + arg.replace(/\"/g, '\"\"') + '\"'\n    }\n    return arg\n}\n\nconst isWin = new UAParser().getOS().name === 'Windows'\n\nexport function shellEscape (stringOrArray: string[]|string): string {\n    const ret: string[] = []\n\n    const escapePath = isWin ? escapeWin : escapeUnix\n\n    if (typeof stringOrArray == 'string') {\n        return escapePath(stringOrArray)\n    } else {\n        stringOrArray.forEach(function (member) {\n            ret.push(escapePath(member))\n        })\n        return ret.join(' ')\n    }\n}\n"
  },
  {
    "path": "warpgate-web/src/gateway/lib/store.ts",
    "content": "import { writable } from 'svelte/store'\nimport { api, type Info } from './api'\n\nexport const serverInfo = writable<Info|undefined>(undefined)\n\nexport async function reloadServerInfo (): Promise<void> {\n    serverInfo.set(await api.getInfo())\n}\n"
  },
  {
    "path": "warpgate-web/src/gateway/login.ts",
    "content": "import { mount } from 'svelte'\nimport Login from './Login.svelte'\n\nconst app = {}\nmount(Login, {\n    target: document.getElementById('app')!,\n})\n\nexport default app\n"
  },
  {
    "path": "warpgate-web/src/lib.rs",
    "content": "use std::collections::HashMap;\n\nuse rust_embed::RustEmbed;\nuse serde::Deserialize;\n\n#[derive(RustEmbed)]\n#[folder = \"../warpgate-web/dist\"]\npub struct Assets;\n\n#[derive(thiserror::Error, Debug)]\npub enum LookupError {\n    #[error(\"I/O\")]\n    Io(#[from] std::io::Error),\n\n    #[error(\"Serde\")]\n    Serde(#[from] serde_json::Error),\n\n    #[error(\"File not found in manifest\")]\n    FileNotFound,\n\n    #[error(\"Manifest not found\")]\n    ManifestNotFound,\n}\n\n#[derive(Deserialize, Clone)]\npub struct ManifestEntry {\n    pub file: String,\n    pub css: Option<Vec<String>>,\n}\n\npub fn lookup_built_file(source: &str) -> Result<ManifestEntry, LookupError> {\n    let file = Assets::get(\".vite/manifest.json\").ok_or(LookupError::ManifestNotFound)?;\n\n    let obj: HashMap<String, ManifestEntry> = serde_json::from_slice(&file.data)?;\n\n    obj.get(source).cloned().ok_or(LookupError::FileNotFound)\n}\n"
  },
  {
    "path": "warpgate-web/src/theme/_theme.scss",
    "content": "@use \"sass:map\";\n\n@mixin button-variant(\n    $background,\n    $border,\n    $color: color-contrast($background),\n    $hover-background: if($color == $color-contrast-dark, shade-color($background, $btn-hover-bg-shade-amount), tint-color($background, $btn-hover-bg-tint-amount)),\n    $hover-border: if($color == $color-contrast-dark, shade-color($border, $btn-hover-border-shade-amount), tint-color($border, $btn-hover-border-tint-amount)),\n    $hover-color: color-contrast($hover-background),\n    $active-background: if($color == $color-contrast-dark, shade-color($background, $btn-active-bg-shade-amount), tint-color($background, $btn-active-bg-tint-amount)),\n    $active-border: if($color == $color-contrast-dark, shade-color($border, $btn-active-border-shade-amount), tint-color($border, $btn-active-border-tint-amount)),\n    $active-color: color-contrast($active-background),\n    $disabled-background: $background,\n    $disabled-border: $border,\n    $disabled-color: $btn-disabled-color,\n    $text-color: if($color == $color-contrast-light, shade-color($background, $btn-color-shade-amount), tint-color($background, $btn-color-tint-amount))\n) {\n    $real-background: if($color == $color-contrast-dark, shade-color($background, $btn-bg-shade-amount), tint-color($background, $btn-bg-tint-amount));\n    $real-color: if($color == $color-contrast-light, shade-color($background, $btn-color-shade-amount), tint-color($background, $btn-color-tint-amount));\n    --#{$prefix}btn-color: #{$real-color};\n    --#{$prefix}btn-bg: #{$real-background};\n    --#{$prefix}btn-border-color: #{if($color == $color-contrast-dark, shade-color($background, $btn-border-shade-amount), tint-color($background, $btn-border-tint-amount))};\n    --#{$prefix}btn-hover-color: #{$hover-color};\n    --#{$prefix}btn-hover-bg: #{$hover-background};\n    --#{$prefix}btn-hover-border-color: #{$hover-border};\n    --#{$prefix}btn-focus-shadow-rgb: #{to-rgb(mix($color, $border, 15%))};\n    --#{$prefix}btn-active-color: #{$active-color};\n    --#{$prefix}btn-active-bg: #{$active-background};\n    --#{$prefix}btn-active-border-color: #{$active-border};\n    --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow};\n    --#{$prefix}btn-disabled-color: #{$disabled-color};\n    --#{$prefix}btn-disabled-bg: #{$real-background};\n}\n\n// Layout & components\n@import \"bootstrap/scss/root\";\n@import \"bootstrap/scss/reboot\";\n@import \"bootstrap/scss/type\";\n// @import \"bootstrap/scss/images\";\n@import \"bootstrap/scss/containers\";\n@import \"bootstrap/scss/grid\";\n@import \"bootstrap/scss/tables\";\n@import \"bootstrap/scss/forms\";\n@import \"bootstrap/scss/buttons\";\n@import \"bootstrap/scss/transitions\";\n@import \"bootstrap/scss/dropdown\";\n@import \"bootstrap/scss/button-group\";\n// @import \"bootstrap/scss/nav\";\n// @import \"bootstrap/scss/navbar\";\n@import \"bootstrap/scss/card\";\n// @import \"bootstrap/scss/accordion\";\n// @import \"bootstrap/scss/breadcrumb\";\n@import \"bootstrap/scss/pagination\";\n@import \"bootstrap/scss/badge\";\n@import \"bootstrap/scss/alert\";\n// @import \"bootstrap/scss/progress\";\n@import \"bootstrap/scss/list-group\";\n@import \"bootstrap/scss/close\";\n// @import \"bootstrap/scss/toasts\";\n@import \"bootstrap/scss/modal\";\n@import \"bootstrap/scss/tooltip\";\n// @import \"bootstrap/scss/popover\";\n// @import \"bootstrap/scss/carousel\";\n@import \"bootstrap/scss/spinners\";\n// @import \"bootstrap/scss/offcanvas\";\n// @import \"bootstrap/scss/placeholders\";\n\n// Helpers\n@import \"bootstrap/scss/helpers\";\n\n// Utilities\n@import \"bootstrap/scss/utilities/api\";\n\n$font-family-os: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\" !default;\n\n\n#app {\n    display: block;\n    -webkit-font-smoothing: antialiased;\n}\n\n.page-summary-bar {\n    display: flex;\n    align-items: center;\n    margin: 0.25rem 0 1.5rem;\n    gap: 1rem;\n\n    h1 {\n        font-family: 'Poppins';\n        font-weight: 700;\n        font-size: 2.5rem;\n        margin: 0;\n    }\n\n    .counter {\n        display: inline-block;\n        padding: 0px 10px;\n        background: var(--bs-body-color);\n        color: var(--bs-body-bg);\n        border-radius: 5px;\n    }\n}\n\n// .alert {\n//     border-top-style: none;\n//     border-bottom-style: none;\n//     border-right-style: none;\n//     border-left-width: 2px;\n//     font-style: italic;\n//     background: rgba(0,0,0,.25) !important;\n//     padding: 0.7rem 1rem;\n//     margin: 1rem 0;\n// }\n\nfooter {\n    display: flex;\n    align-items: center;\n    padding: .5rem 0;\n    margin: 2rem 0 1rem;\n    border-top: 1px solid rgba($body-color, .2);\n}\n\ninput:-webkit-autofill {\n    -webkit-background-clip: text;\n    -webkit-box-shadow: 0 0 0 50px $input-bg inset;\n    -webkit-text-fill-color: $input-color;\n}\n\ninput:-webkit-autofill:focus {\n    -webkit-background-clip: text;\n    -webkit-box-shadow: 0 0 0 50px $input-focus-bg inset;\n    -webkit-text-fill-color: $input-focus-color;\n}\n\n.badge {\n    font-family: $font-family-os;\n}\n\n.btn {\n    text-decoration: underline;\n    text-decoration-color: var(--#{$prefix}btn-active-bg);\n}\n\n.page-item.active .page-link {\n    text-decoration: underline;\n}\n\n// Fix placeholder text with floating FormGroup labels\n.form-floating>.form-control::placeholder {\n    color: revert;\n}\n\n.form-floating>.form-control:not(:focus)::placeholder {\n    color: transparent;\n}\n\n@include media-breakpoint-up(md) {\n    .narrow-page {\n        width: map.get($grid-breakpoints, \"md\");\n    }\n}\n\na {\n    text-decoration-color: var(--wg-link-underline-color);\n    text-underline-offset: 2px;\n\n    &:hover, &.active {\n        text-decoration-color: var(--wg-link-hover-underline-color);\n    }\n}\n\n\n// Make these vars reusable from everywhere\nbody {\n    --bs-list-group-color: #{$list-group-color};\n    --bs-list-group-bg: #{$list-group-bg};\n    --bs-list-group-border-color: #{$list-group-border-color};\n    --bs-list-group-border-width: #{$list-group-border-width};\n    --bs-list-group-border-radius: #{$list-group-border-radius};\n    --bs-list-group-item-padding-x: #{$list-group-item-padding-x};\n    --bs-list-group-item-padding-y: #{$list-group-item-padding-y};\n    --bs-list-group-action-color: #{$list-group-action-color};\n    --bs-list-group-action-hover-color: #{$list-group-action-hover-color};\n    --bs-list-group-action-hover-bg: #{$list-group-hover-bg};\n    --bs-list-group-action-active-color: #{$list-group-action-active-color};\n    --bs-list-group-action-active-bg: #{$list-group-action-active-bg};\n    --bs-list-group-disabled-color: #{$list-group-disabled-color};\n    --bs-list-group-disabled-bg: #{$list-group-disabled-bg};\n    --bs-list-group-active-color: #{$list-group-active-color};\n    --bs-list-group-active-bg: #{$list-group-active-bg};\n    --bs-list-group-active-border-color: #{$list-group-active-border-color};\n\n    --wg-link-underline-color: #{$link-underline-color};\n    --wg-link-hover-underline-color: #{$link-hover-underline-color};\n}\n\n@each $theme-color, $value in $theme-colors {\n    .text-bg-#{$theme-color} {\n        $color: color-contrast($value);\n        $real-background: if($color == $color-contrast-dark, shade-color($value, $btn-bg-shade-amount), tint-color($value, $btn-bg-tint-amount));\n        $real-color: if($color == $color-contrast-light, shade-color($value, $btn-color-shade-amount), tint-color($value, $btn-color-tint-amount));\n\n        color: $real-color if($enable-important-utilities, !important, null);\n        background-color: $real-background if($enable-important-utilities, !important, null);\n    }\n}\n\nul.pagination {\n    display: flex;\n    align-items: center;\n}\n\n.container-max-sm {\n    margin: auto;\n    max-width: 540px;\n}\n\n.container-max-md {\n    margin: auto;\n    max-width: 720px;\n}\n\n.container-max-lg {\n    margin: auto;\n    max-width: 960px;\n}\n\n.modal-content {\n    box-shadow: 0 0 10px rgba(0, 0, 0, .25);\n}\n\n.modal-header {\n    padding-bottom: 0;\n\n    h5 {\n        margin: auto;\n        text-align: center;\n    }\n}\n\n.modal-footer {\n    border: none;\n    display: flex;\n    padding-top: 0;\n\n    .modal-button {\n        display: block;\n        flex: 1 0 0;\n    }\n\n    @media (max-width: 576px) {\n        flex-direction: column;\n        align-items: stretch;\n\n        .modal-button {\n            flex: auto;\n        }\n    }\n}\n\n@media (prefers-reduced-motion: reduce) {\n    // #1271: sveltestrap modals are broken when `prefers-reduced-motion` is on\n    .modal.fade {\n        opacity: 1 !important;\n    }\n\n    .modal-backdrop.fade {\n        opacity: var(--bs-backdrop-opacity) !important;\n    }\n}\n\n.abbreviate {\n    display: inline-block;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.dropdown-item {\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n"
  },
  {
    "path": "warpgate-web/src/theme/fonts.css",
    "content": "@font-face {\n    font-family: 'Poppins';\n    font-style: normal;\n    font-display: block; /* changed */\n    font-weight: 700;\n    src: url(./../../node_modules/@fontsource/poppins/files/poppins-latin-700-normal.woff2) format('woff2'), url(./../../node_modules/@fontsource/poppins/files/poppins-latin-700-normal.woff) format('woff');\n}\n"
  },
  {
    "path": "warpgate-web/src/theme/index.ts",
    "content": "import '@fontsource/work-sans'\nimport './fonts.css'\n\nimport { get, writable } from 'svelte/store'\n\ntype ThemeFileName = 'dark'|'light'\ntype ThemeName = ThemeFileName|'auto'\n\nconst savedTheme = (localStorage.getItem('theme') ?? 'auto') as ThemeName\nexport const currentTheme = writable(savedTheme)\nexport const currentThemeFile = writable<ThemeFileName>('dark')\n\nconst styleElement = document.createElement('style')\ndocument.head.appendChild(styleElement)\n\nfunction loadThemeFile (name: ThemeFileName) {\n    currentThemeFile.set(name)\n    if (name === 'dark') {\n        return import('./theme.dark.scss?inline')\n    }\n    return import('./theme.light.scss?inline')\n}\n\nasync function loadTheme (name: ThemeFileName) {\n    const theme = (await loadThemeFile(name)).default\n    styleElement.innerHTML = theme\n}\n\n\nwindow.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {\n    if (get(currentTheme) === 'auto') {\n        loadTheme(event.matches ? 'dark' : 'light')\n    }\n})\n\n\nexport function setCurrentTheme (theme: ThemeName): void {\n    localStorage.setItem('theme', theme)\n    currentTheme.set(theme)\n    if (theme === 'auto') {\n        if (window.matchMedia?.('(prefers-color-scheme: dark)').matches) {\n            loadTheme('dark')\n        } else {\n            loadTheme('light')\n        }\n    } else {\n        loadTheme(theme)\n    }\n}\n\nsetCurrentTheme(savedTheme)\n"
  },
  {
    "path": "warpgate-web/src/theme/theme.dark.scss",
    "content": "@import \"./vars.dark\";\n@import \"bootstrap/scss/functions\";\n@import \"bootstrap/scss/maps\";\n@import \"bootstrap/scss/mixins\";\n\n@mixin button-outline-variant(\n  $color,\n  $color-hover: color-contrast($color),\n  $active-background: $color,\n  $active-border: $color,\n  $active-color: color-contrast($active-background)\n) {\n  --#{$prefix}btn-color: lighten(#{$color}, 50%);\n  --#{$prefix}btn-border-color: #{$color};\n  --#{$prefix}btn-hover-color: #{$color-hover};\n  --#{$prefix}btn-hover-bg: #{$active-background};\n  --#{$prefix}btn-hover-border-color: #{$active-border};\n  --#{$prefix}btn-focus-shadow-rgb: #{to-rgb($color)};\n  --#{$prefix}btn-active-color: #{$active-color};\n  --#{$prefix}btn-active-bg: #{$active-background};\n  --#{$prefix}btn-active-border-color: #{$active-border};\n  --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow};\n  --#{$prefix}btn-disabled-color: #{$color};\n  --#{$prefix}btn-disabled-bg: transparent;\n  --#{$prefix}gradient: none;\n}\n\n@import \"bootstrap/scss/utilities\";\n\n@import \"theme\";\n\nheader {\n    border-bottom: 1px solid rgba($body-color, .2);\n}\n\n.list-group-item-action {\n    transition: 0.125s ease-out background;\n}\n\nbody {\n    color-scheme: dark;\n}\n"
  },
  {
    "path": "warpgate-web/src/theme/theme.light.scss",
    "content": "@import \"./vars.light\";\n@import \"bootstrap/scss/functions\";\n@import \"bootstrap/scss/maps\";\n@import \"bootstrap/scss/mixins\";\n@import \"bootstrap/scss/utilities\";\n@import \"theme\";\n\nheader {\n    border-bottom: 1px solid rgba($body-color, .75);\n}\n\nbody {\n    color-scheme: light;\n}\n"
  },
  {
    "path": "warpgate-web/src/theme/vars.common.scss",
    "content": "$pagination-bg: transparent;\n$pagination-disabled-bg: transparent;\n$pagination-disabled-color: $btn-link-disabled-color;\n$pagination-border-width: 0;\n$pagination-active-color: $link-hover-color;\n$pagination-active-bg: transparent;\n$pagination-hover-bg: transparent;\n$pagination-focus-bg: transparent;\n$modal-header-border-color: transparent;\n$dropdown-link-hover-bg: transparent;\n\n$btn-padding-x: 1.5rem;\n\n$btn-bg-shade-amount: 75%;\n$btn-bg-tint-amount: 60%;\n$btn-border-shade-amount: 75%;\n$btn-border-tint-amount: 65%;\n$btn-color-shade-amount: 10%;\n$btn-color-tint-amount: 50%;\n\n$btn-hover-bg-shade-amount: 60%;\n$btn-hover-bg-tint-amount: 50%;\n$btn-hover-border-shade-amount: 60%;\n$btn-hover-border-tint-amount: 10%;\n\n$btn-active-bg-shade-amount: 50%;\n// $btn-active-bg-tint-amount\n$btn-active-border-shade-amount: 40%;\n// $btn-active-border-tint-amount\n\n$tooltip-color: #c1c9e4;\n\n$badge-font-size: .8em;\n$badge-font-weight: 400;\n$badge-padding-y: .55em;\n$badge-padding-x: .85em;\n\n$alert-border-width: 0;\n$alert-border-scale: -30%;\n\n$table-color: var(--bs-body-color);\n$table-bg: var(--bs-body-bg);\n"
  },
  {
    "path": "warpgate-web/src/theme/vars.dark.scss",
    "content": "@import \"bootstrap/scss/functions\";\n\n$body-bg: #14141a;\n$body-color: #c1c9e4;\n\n$font-family-sans-serif: \"Work Sans\", system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", \"Liberation Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n$link-color: $body-color;\n$list-group-bg: transparent;\n\n\n$blue:    #449fbe;\n$purple:  #5C398F;\n$pink:    #B53D6D;\n$red:     #D35D47;\n$orange:  #fd7e14;\n$yellow:  #D38F47;\n$green:   #87C041;\n$teal:    #20c997;\n$cyan:    #0dcaf0;\n\n$primary: $blue;\n$secondary: #748d95;\n$light: transparent;\n$info: $blue;\n\n\n$component-active-bg: $primary;\n\n$input-bg: #1c1c24;\n$input-border-color: #ced4da40;\n$input-color: #ccc;\n$input-focus-bg: #1b1b22;\n$input-focus-border-color: tint-color($component-active-bg, 25%) ;\n$input-disabled-bg: $input-bg;\n\n$input-btn-focus-color-opacity: .25;\n$input-btn-border-width: 2px;\n\n$border-color: rgba(#fff, .125);\n\n$list-group-color: $body-color;\n$list-group-border-color: $border-color;\n$list-group-hover-bg: rgba(#fff, .0625);\n$list-group-action-color: $body-color;\n$list-group-action-hover-color: $body-color;\n$list-group-action-active-color: $body-color;\n$list-group-action-active-bg: rgba(#fff, .125);\n\n$link-hover-color: lighten($link-color, 25%);\n\n$badge-color: $body-bg;\n\n$text-muted: rgba($body-color, .5);\n\n$modal-content-bg: $body-bg;\n\n$btn-close-color: $secondary;\n$btn-link-disabled-color: rgba(255, 255, 255, .5);\n$btn-disabled-color: #969696;\n\n$alert-bg-scale: 100%;\n$alert-border-scale: 50%;\n$alert-color-scale: 0%;\n\n$code-color: #84f1fe;\n\n$form-check-input-border: 2px solid $input-border-color;\n$form-switch-color: $secondary;\n$form-check-color: $primary;\n\n@import \"bootstrap/scss/variables\";\n@import \"./vars.common.scss\";\n\n$link-underline-color: rgba($link-color, .5);\n$link-hover-underline-color: rgba($link-hover-color, .75);\n\n\n\n$form-check-input-width: 1.3em;\n$form-switch-width: 2.5em;\n$form-switch-padding-start:       3em;\n\n$form-check-input-checked-border-color:   shade-color($form-check-color, $btn-active-border-shade-amount);\n$form-check-input-checked-bg-color:       shade-color($form-check-color, $btn-active-bg-shade-amount);\n\n$primary-bg-subtle:       shade-color($primary, 80%);\n$secondary-bg-subtle:     shade-color($secondary, 80%);\n$success-bg-subtle:       shade-color($success, 80%);\n$info-bg-subtle:          shade-color($info, 80%);\n$warning-bg-subtle:       shade-color($warning, 80%);\n$danger-bg-subtle:        shade-color($danger, 80%);\n\n$primary-text-emphasis:   tint-color($primary, 60%);\n$secondary-text-emphasis: tint-color($secondary, 60%);\n$success-text-emphasis:   tint-color($success, 60%);\n$info-text-emphasis:      tint-color($info, 60%);\n$warning-text-emphasis:   tint-color($warning, 60%);\n$danger-text-emphasis:    tint-color($danger, 60%);\n"
  },
  {
    "path": "warpgate-web/src/theme/vars.light.scss",
    "content": "@import \"bootstrap/scss/functions\";\n\n$body-bg: #fffcf6;\n$body-color: #555;\n$font-family-sans-serif: \"Work Sans\", system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", \"Liberation Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n$link-color: $body-color;\n$list-group-bg: transparent;\n\n$blue:    #3573ac !default;\n$purple:  #5C398F !default;\n$pink:    #B53D6D !default;\n$red:     #842716 !default;\n$orange:  #a84c01 !default;\n$yellow:  #916101 !default;\n$green:   #417106 !default;\n$teal:    #20c997 !default;\n$cyan:    #0e6b7e !default;\n\n// $primary: $blue;\n$secondary: #506579;\n\n$btn-disabled-color: #969696;\n\n\n$form-switch-color: $secondary;\n// $form-check-color: $primary;\n$form-check-input-border: 2px solid rgba(#000, .25);\n\n@import \"bootstrap/scss/variables\";\n@import \"./vars.common.scss\";\n\n$form-check-color: $primary;\n\n$text-muted: $gray-500;\n\n$link-underline-color: rgba($body-color, 0.25);\n$link-hover-underline-color: $body-color;\n\n\n$form-check-input-width: 1.3em;\n$form-switch-width: 2.5em;\n$form-switch-padding-start:       3em;\n\n$form-check-input-checked-border-color:   tint-color($form-check-color, $btn-active-border-tint-amount);\n$form-check-input-checked-bg-color:       tint-color($form-check-color, $btn-active-bg-tint-amount);\n\n$primary-bg-subtle:       tint-color($primary, 90%);\n$secondary-bg-subtle:     tint-color($secondary, 90%);\n$success-bg-subtle:       tint-color($success, 90%);\n$info-bg-subtle:          tint-color($info, 90%);\n$warning-bg-subtle:       tint-color($warning, 90%);\n$danger-bg-subtle:        tint-color($danger, 90%);\n\n$primary-text-emphasis:   shade-color($primary, 10%);\n$secondary-text-emphasis: shade-color($secondary, 10%);\n$success-text-emphasis:   shade-color($success, 10%);\n$info-text-emphasis:      shade-color($info, 10%);\n$warning-text-emphasis:   shade-color($warning, 10%);\n$danger-text-emphasis:    shade-color($danger, 10%);\n"
  },
  {
    "path": "warpgate-web/src/vite-env.d.ts",
    "content": "/// <reference types=\"svelte\" />\n/// <reference types=\"vite/client\" />\n\n// eslint-disable-next-line @typescript-eslint/no-type-alias\ndeclare type GlobalFetch = WindowOrWorkerGlobalScope\n"
  },
  {
    "path": "warpgate-web/svelte.config.js",
    "content": "import sveltePreprocess from 'svelte-preprocess'\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n    compilerOptions: {\n        dev: true,\n        compatibility: {\n          componentApi: 4,\n        },\n    },\n    preprocess: sveltePreprocess({\n        sourceMap: true,\n    }),\n    vitePlugin: {\n        prebundleSvelteLibraries: true,\n    },\n}\n\nexport default config\n"
  },
  {
    "path": "warpgate-web/tsconfig.json",
    "content": "{\n    \"extends\": \"@tsconfig/svelte/tsconfig.json\",\n    \"compilerOptions\": {\n        \"target\": \"esnext\",\n        \"useDefineForClassFields\": true,\n        \"module\": \"esnext\",\n        \"resolveJsonModule\": true,\n        \"strictNullChecks\": true,\n        \"baseUrl\": \".\",\n        \"verbatimModuleSyntax\": true,\n        \"noUnusedLocals\": false,\n        \"noUncheckedIndexedAccess\": true,\n        /**\n        * Typecheck JS in `.svelte` and `.js` files by default.\n        * Disable checkJs if you'd like to use dynamic types in JS.\n        * Note that setting allowJs false does not prevent the use\n        * of JS in `.svelte` files.\n        */\n        \"types\": [],\n        \"allowJs\": true,\n        \"checkJs\": true,\n        \"paths\": {\n            \"*\": [\n                \"src/*\"\n            ]\n        }\n    },\n    \"include\": [\n        \"src/**/*.d.ts\",\n        \"src/**/*.ts\",\n        \"src/*.ts\",\n        \"src/**/*.js\",\n        \"src/**/*.svelte\"\n    ],\n    \"exclude\": [\n        \"node_modules/@types/node/**\",\n        \"src/*/lib/api-client\",\n    ],\n    \"references\": [\n        {\n            \"path\": \"./tsconfig.node.json\"\n        }\n    ]\n}\n"
  },
  {
    "path": "warpgate-web/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\"\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "warpgate-web/vite.config.ts",
    "content": "import { defineConfig } from 'vite'\nimport { svelte } from '@sveltejs/vite-plugin-svelte'\nimport tsconfigPaths from 'vite-tsconfig-paths'\nimport { checker } from 'vite-plugin-checker'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n    plugins: [\n        svelte(),\n        tsconfigPaths(),\n        // checker({ typescript: true }),\n    ],\n    base: '/@warpgate',\n    build: {\n        sourcemap: true,\n        manifest: true,\n        commonjsOptions: {\n            include: [\n                'src/gateway/lib/api-client/dist/*.js',\n                'src/admin/lib/api-client/dist/*.js',\n                '**/*.js',\n            ],\n            transformMixedEsModules: true,\n        },\n        rollupOptions: {\n            input: {\n                admin: 'src/admin/index.html',\n                gateway: 'src/gateway/index.html',\n                embed: 'src/embed/index.ts',\n            },\n        },\n    },\n})\n"
  }
]